@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/billed.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Single responsibility: what a model call, or a whole run, was BILLED before it failed — the
2
+ // shape it travels in on a thrown error's `meta.billed`, the reader a job uses to store it, and
3
+ // the arithmetic that adds one call to a run.
4
+ //
5
+ // It exists because a failure after the provider answered is not free: a truncated answer, tool
6
+ // arguments that would not parse, a schema no repair turn satisfied — each was generated and
7
+ // charged for. A job that only sees the error would otherwise have to guess what the attempt cost.
8
+
9
+ import { isUltimateError } from '@ultimat3/core';
10
+ import type { Money } from '@ultimat3/money';
11
+ import type { TokenUsage } from './provider';
12
+
13
+ /** The `meta` key every failure after a provider answered carries its bill under. */
14
+ export const BILLED_META = 'billed';
15
+
16
+ export interface Billed {
17
+ /** Summed over every provider call that ANSWERED — the provider's own counts. */
18
+ readonly usage: TokenUsage;
19
+ /** The price of `usage`, rounded up once. */
20
+ readonly cost: Money;
21
+ /** Provider calls that answered and are counted above. */
22
+ readonly calls: number;
23
+ /**
24
+ * Provider calls that were started and never reported usage: aborted by the caller or a
25
+ * deadline, cut mid-stream, or failed in transport. What they cost CANNOT be known from here —
26
+ * a provider may bill the tokens it generated before the socket closed, and usage is the last
27
+ * thing a response carries. Zero means the figures above are the whole bill.
28
+ */
29
+ readonly unsettled: number;
30
+ }
31
+
32
+ const COUNTERS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const;
33
+
34
+ const isCount = (value: unknown): value is number =>
35
+ typeof value === 'number' && Number.isFinite(value) && value >= 0;
36
+
37
+ const isScale = (value: unknown): value is number =>
38
+ typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 15;
39
+
40
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ ? (value as Record<string, unknown>)
43
+ : undefined;
44
+ }
45
+
46
+ /**
47
+ * What `error` says was billed, or `undefined` when it carries no bill — it was thrown before any
48
+ * provider answered, or it is not one of this package's failures. Total: the value came out of a
49
+ * `catch`, and `meta` is data an app stores and reads back, so every field is checked.
50
+ */
51
+ export function billedOf(error: unknown): Billed | undefined {
52
+ if (!isUltimateError(error)) return undefined;
53
+ let raw: unknown;
54
+ try {
55
+ raw = error.meta?.[BILLED_META];
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ const record = asRecord(raw);
60
+ const usage = asRecord(record?.['usage']);
61
+ const cost = asRecord(record?.['cost']);
62
+ if (record === undefined || usage === undefined || cost === undefined) return undefined;
63
+ if (!COUNTERS.every((key) => isCount(usage[key]))) return undefined;
64
+ if (!isCount(cost['minor']) || typeof cost['currency'] !== 'string') return undefined;
65
+ if (!isCount(record['calls']) || !isCount(record['unsettled'])) return undefined;
66
+ return {
67
+ usage: {
68
+ inputTokens: usage['inputTokens'] as number,
69
+ outputTokens: usage['outputTokens'] as number,
70
+ cacheReadTokens: usage['cacheReadTokens'] as number,
71
+ cacheWriteTokens: usage['cacheWriteTokens'] as number,
72
+ },
73
+ // The scale travels with the amount: an exact cost read without it is a million times over.
74
+ cost: {
75
+ minor: cost['minor'],
76
+ currency: cost['currency'],
77
+ ...(isScale(cost['scale']) ? { scale: cost['scale'] } : {}),
78
+ },
79
+ calls: record['calls'],
80
+ unsettled: record['unsettled'],
81
+ };
82
+ }
83
+
84
+ /** One answered call, as a bill. */
85
+ export function billedCall(usage: TokenUsage, cost: Money): Billed {
86
+ return { usage, cost, calls: 1, unsettled: 0 };
87
+ }
package/src/budget.ts CHANGED
@@ -13,7 +13,8 @@
13
13
  import type { Actor } from '@ultimat3/core';
14
14
  import { assert, asyncContext, finiteCount } from '@ultimat3/core';
15
15
  import type { Money } from '@ultimat3/money';
16
- import { assertSameCurrency } from '@ultimat3/money';
16
+ import { assertSameCurrency, commonScale, exponentOf, minorAt } from '@ultimat3/money';
17
+ import { compareMoney, costScale, ledgerUnits, roundUpCost } from './cost';
17
18
  import { AiBudgetExceededError } from './errors';
18
19
  import type { GenerateRequest, TokenUsage } from './provider';
19
20
  import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from './provider';
@@ -57,11 +58,12 @@ export interface SpendEstimate {
57
58
  }
58
59
 
59
60
  /** Price a request pre-flight. The pessimistic read on purpose — see `estimateCost`. */
60
- export function estimateSpend(request: GenerateRequest): SpendEstimate {
61
+ export function estimateSpend(request: GenerateRequest, marked = false): SpendEstimate {
61
62
  return {
62
63
  inputTokens: estimateInputTokens(request),
63
64
  tokens: estimateTokens(request),
64
- cost: estimateCost(request),
65
+ // `marked`: a provider would send this request with a cache mark, so it can bill as a write.
66
+ cost: estimateCost(request, marked),
65
67
  };
66
68
  }
67
69
 
@@ -186,7 +188,10 @@ export interface BudgetLedgerInput {
186
188
 
187
189
  export interface BudgetReport {
188
190
  readonly requestTokens: number;
191
+ /** Everything recorded, rounded up ONCE to the currency's minor unit. */
189
192
  readonly cost: Money;
193
+ /** The same sum exactly, at `costScale(currency)` — micro-units of the minor unit. */
194
+ readonly costExact: Money;
190
195
  readonly limits: BudgetLimits;
191
196
  readonly actorSpent: number;
192
197
  readonly orgSpent: number;
@@ -198,7 +203,8 @@ export class BudgetLedger {
198
203
  private readonly orgKey: string | undefined;
199
204
  private readonly store: BudgetStore;
200
205
  private requestTokens = 0;
201
- private costMinor = 0;
206
+ /** Summed EXACTLY, in micro-units of the minor unit, and rounded up once — when it is read. */
207
+ private costMicro = 0n;
202
208
  private readonly currency: string;
203
209
  /**
204
210
  * The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
@@ -334,8 +340,11 @@ export class BudgetLedger {
334
340
  async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
335
341
  // Up the chain, because `derive` copies the currency: a scope's reported cost is its own
336
342
  // calls plus every call made under a ledger derived from it.
343
+ // `cost` may be exact (`GenerateResult.costExact`, a finer `scale`) or already rounded; either
344
+ // way it is widened to one scale and summed there, so fourteen cheap calls are rounded once.
345
+ const micro = ledgerUnits(cost);
337
346
  for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
338
- l.costMinor += cost.minor;
347
+ l.costMicro += micro;
339
348
  }
340
349
  await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
341
350
  }
@@ -373,13 +382,23 @@ export class BudgetLedger {
373
382
  async report(): Promise<BudgetReport> {
374
383
  return {
375
384
  requestTokens: this.requestTokens,
376
- cost: { minor: this.costMinor, currency: this.currency },
385
+ cost: roundUpCost(this.exactCost()),
386
+ costExact: this.exactCost(),
377
387
  limits: this.limits,
378
388
  actorSpent: this.actorKey === undefined ? 0 : await this.store.spent(this.actorKey),
379
389
  orgSpent: this.orgKey === undefined ? 0 : await this.store.spent(this.orgKey),
380
390
  };
381
391
  }
382
392
 
393
+ private exactCost(): Money {
394
+ const minor = Number(this.costMicro);
395
+ return {
396
+ minor: Number.isSafeInteger(minor) ? minor : Number.MAX_SAFE_INTEGER,
397
+ currency: this.currency,
398
+ scale: costScale(this.currency),
399
+ };
400
+ }
401
+
383
402
  private assertScope(scope: string, limit: number | undefined, spent: number, want: number): void {
384
403
  if (limit === undefined) return;
385
404
  const remaining = limit - spent;
@@ -393,13 +412,21 @@ export class BudgetLedger {
393
412
  const limit = this.limits.costPerCall;
394
413
  if (limit === undefined) return;
395
414
  assertSameCurrency(limit, cost);
396
- if (cost.minor > limit.minor) {
415
+ if (compareMoney(cost, limit) > 0) {
416
+ // ONE scale for all three figures: the estimate is in the currency's minor units and the
417
+ // ceiling may be finer, and printing each in its own under one label compares nothing.
418
+ const scale = commonScale(cost, limit);
419
+ const ceiling = Number(minorAt(limit, scale));
397
420
  throw new AiBudgetExceededError({
398
421
  scope: 'costPerCall',
399
- requested: cost.minor,
400
- remaining: limit.minor,
401
- limit: limit.minor,
402
- unit: `${limit.currency} minor units`,
422
+ requested: Number(minorAt(cost, scale)),
423
+ remaining: ceiling,
424
+ limit: ceiling,
425
+ // The currency's own minor units say so as they always did; a finer ceiling names its scale.
426
+ unit:
427
+ scale === exponentOf(limit.currency)
428
+ ? `${limit.currency} minor units`
429
+ : `${limit.currency} at scale ${scale}`,
403
430
  });
404
431
  }
405
432
  }
@@ -455,7 +482,8 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef
455
482
  if (a === undefined) return b;
456
483
  if (b === undefined) return a;
457
484
  assertSameCurrency(a, b);
458
- return a.minor <= b.minor ? a : b;
485
+ // As money: a ceiling at a finer `scale` is not larger for having a larger `minor`.
486
+ return compareMoney(a, b) <= 0 ? a : b;
459
487
  }
460
488
 
461
489
  const storage = asyncContext<BudgetLedger>('an AI budget');
package/src/cost.ts ADDED
@@ -0,0 +1,146 @@
1
+ // Single responsibility: the money arithmetic over a call's reported usage — what it cost exactly,
2
+ // and that figure rounded up to the currency's own minor unit, once.
3
+ //
4
+ // Exact, in integers: a price is `Money` per million tokens at whatever `scale` the row states
5
+ // (`{ minor: 75, currency: 'USD', scale: 3 }` is $0.075), and a cost is `Money` at
6
+ // `COST_SCALE_DIGITS` more digits than the currency's minor unit — millionths of a cent for USD.
7
+ // The multiplication is done in bigint, so no call, however large, loses a digit on the way.
8
+
9
+ import type { Money } from '@ultimat3/money';
10
+ import { commonScale, exponentOf, minorAt, moneyScale } from '@ultimat3/money';
11
+ import type { ModelId, ModelSpec } from './models';
12
+ import { modelSpec, PRICE_SCALE_DIGITS } from './models';
13
+ import type { TokenUsage } from './provider';
14
+
15
+ /** Digits an exact cost carries beyond the currency's minor unit: micro-units of it. */
16
+ export const COST_SCALE_DIGITS = PRICE_SCALE_DIGITS;
17
+
18
+ const MICRO = 10n ** BigInt(COST_SCALE_DIGITS);
19
+ const MILLION = 1_000_000n;
20
+ /** Prices are summed in TWENTIETHS so the default cache multipliers (0.1x, 1.25x) stay whole. */
21
+ const TWENTIETHS = 20n;
22
+
23
+ /** The scale an exact cost in `currency` is stated at. */
24
+ export const costScale = (currency: string): number => exponentOf(currency) + COST_SCALE_DIGITS;
25
+
26
+ /**
27
+ * What one call cost, EXACTLY to a micro-unit of the currency's minor unit (rounded up at that
28
+ * digit, which no real price reaches). `Money` with an explicit `scale` — never a float, never a
29
+ * second type: `{ minor: 105_000, currency: 'USD', scale: 8 }` is 0.105 cents.
30
+ *
31
+ * The one price lookup: a model the app never `registerModel`-ed is `X_AI_MODEL_UNKNOWN` here, so
32
+ * no call is priced — and no budget reserved — against a number nobody chose.
33
+ */
34
+ export function exactCostOf(model: ModelId, usage: TokenUsage): Money {
35
+ const spec = modelSpec(model);
36
+ const { currency } = spec.inputPerMillion;
37
+ const scale = costScale(currency);
38
+ return {
39
+ minor: toSafe(ceilDiv(units(spec, usage, scale), TWENTIETHS * MILLION)),
40
+ currency,
41
+ scale,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Cost of one call in the currency's own minor units — the figure every existing reader of
47
+ * `cost.minor` means. Rounded UP: a fraction we round away is money the framework silently
48
+ * absorbs, and under-reporting spend defeats a budget. Rounded ONCE, over the whole call: the
49
+ * input and output halves were each rounded up before, so a call worth a tenth of a cent read two.
50
+ */
51
+ export function costOf(model: ModelId, usage: TokenUsage): Money {
52
+ const spec = modelSpec(model);
53
+ const { currency } = spec.inputPerMillion;
54
+ const total = units(spec, usage, costScale(currency));
55
+ return { minor: toSafe(ceilDiv(total, TWENTIETHS * MILLION * MICRO)), currency };
56
+ }
57
+
58
+ /** An exact cost (or any finer `Money`) rounded up to its currency's minor unit. */
59
+ export function roundUpCost(exact: Money): Money {
60
+ const from = exact.scale ?? exponentOf(exact.currency);
61
+ const drop = 10n ** BigInt(Math.max(0, from - exponentOf(exact.currency)));
62
+ return { minor: toSafe(ceilDiv(BigInt(exact.minor), drop)), currency: exact.currency };
63
+ }
64
+
65
+ /** `usage` priced, in twentieths of (a unit at `scale`) per million tokens — before any division. */
66
+ function units(spec: ModelSpec, usage: TokenUsage, scale: number): bigint {
67
+ const input = minorAt(spec.inputPerMillion, scale);
68
+ // A cache read is 0.1x input (2/20) and a 5-minute write 1.25x (25/20) unless the row states
69
+ // its own — a vendor's 0.05x or 0.025x read is a row, which a fixed 0.1x overstated.
70
+ const read =
71
+ spec.cacheReadPerMillion === undefined
72
+ ? input * 2n
73
+ : minorAt(spec.cacheReadPerMillion, scale) * TWENTIETHS;
74
+ const write =
75
+ spec.cacheWritePerMillion === undefined
76
+ ? input * 25n
77
+ : minorAt(spec.cacheWritePerMillion, scale) * TWENTIETHS;
78
+ return (
79
+ count(usage.inputTokens) * input * TWENTIETHS +
80
+ count(usage.cacheReadTokens) * read +
81
+ count(usage.cacheWriteTokens) * write +
82
+ count(usage.outputTokens) * minorAt(spec.outputPerMillion, scale) * TWENTIETHS
83
+ );
84
+ }
85
+
86
+ /** A token count as a bigint. Usage is the provider's number: floored, and never negative. */
87
+ const count = (tokens: number): bigint =>
88
+ Number.isFinite(tokens) && tokens > 0 ? BigInt(Math.floor(tokens)) : 0n;
89
+
90
+ const ceilDiv = (numerator: bigint, denominator: bigint): bigint =>
91
+ (numerator + denominator - 1n) / denominator;
92
+
93
+ /** Back to the `number` `Money` carries. Past 2^53 minor units there is no honest answer. */
94
+ const toSafe = (value: bigint): number =>
95
+ value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
96
+
97
+ /**
98
+ * `a` against `b` as MONEY — negative, zero or positive — at the finer of their two scales. Never
99
+ * `a.minor - b.minor`: `{ minor: 500, scale: 3 }` is fifty cents, and read by `minor` alone it
100
+ * outranked four dollars.
101
+ */
102
+ export function compareMoney(a: Money, b: Money): number {
103
+ const scale = commonScale(a, b);
104
+ const left = minorAt(a, scale);
105
+ const right = minorAt(b, scale);
106
+ return left < right ? -1 : left > right ? 1 : 0;
107
+ }
108
+
109
+ /** Two amounts of one currency as one, at the finer scale — stated only when either stated one. */
110
+ export function addMoney(a: Money, b: Money): Money {
111
+ const scale = commonScale(a, b);
112
+ const minor = toSafe(minorAt(a, scale) + minorAt(b, scale));
113
+ return a.scale === undefined && b.scale === undefined
114
+ ? { minor, currency: a.currency }
115
+ : { minor, currency: a.currency, scale };
116
+ }
117
+
118
+ /**
119
+ * A cost as a `Provider` reported it, made bookable. The built-in providers price in integers, but
120
+ * `Provider` is an interface an app implements, and its `cost.minor` was summed as a plain number
121
+ * until the ledger went exact: a fraction was accepted. So a fraction is ROUNDED UP (never down:
122
+ * under-reporting spend defeats a budget), and a value that is no amount at all — `NaN`, an
123
+ * infinity, a negative, which a ledger would read as a credit — is booked as zero. Never a throw:
124
+ * this runs after the provider was paid, and a bookkeeping error must not cost the answer.
125
+ */
126
+ export function bookableCost(cost: Money): Money {
127
+ const minor = Number.isFinite(cost.minor) && cost.minor > 0 ? Math.ceil(cost.minor) : 0;
128
+ const scale = Number.isInteger(cost.scale) ? cost.scale : undefined;
129
+ return scale === undefined
130
+ ? { minor, currency: cost.currency }
131
+ : { minor, currency: cost.currency, scale };
132
+ }
133
+
134
+ /**
135
+ * `cost` in units of the ledger's scale (`costScale`), as a bigint. A coarser cost is widened
136
+ * exactly; a FINER one — a provider pricing to twelve places — is rounded UP to the ledger's
137
+ * digit, here, in bigint: `minorAt` only widens and throws on the rest, and `rescale` answers a
138
+ * `Money` whose `minor` can leave the safe range.
139
+ */
140
+ export function ledgerUnits(cost: Money): bigint {
141
+ const booked = bookableCost(cost);
142
+ const target = costScale(booked.currency);
143
+ const from = moneyScale(booked);
144
+ if (from <= target) return minorAt(booked, target);
145
+ return ceilDiv(BigInt(booked.minor), 10n ** BigInt(from - target));
146
+ }
@@ -2,6 +2,7 @@
2
2
  // from ./provider, which owns the real Messages API, because a double and the socket it stands in
3
3
  // for are two jobs — and the request half had reached its line ceiling holding both.
4
4
 
5
+ import { costOf, exactCostOf } from './cost';
5
6
  import { resolveModel } from './model-resolve';
6
7
  import type { ModelId } from './models';
7
8
  import { modelIds } from './models';
@@ -13,7 +14,7 @@ import type {
13
14
  StreamChunk,
14
15
  TokenUsage,
15
16
  } from './provider';
16
- import { costOf, estimateTextTokens, estimateTokens, messageText } from './provider';
17
+ import { estimateTextTokens, estimateTokens, messageText } from './provider';
17
18
 
18
19
  export interface EchoProviderInput {
19
20
  /** Fixed replies keyed by the last user message, for eval fixtures. */
@@ -69,6 +70,7 @@ export class EchoProvider implements Provider {
69
70
  stopDetails: undefined,
70
71
  usage,
71
72
  cost: costOf(model, usage),
73
+ costExact: exactCostOf(model, usage),
72
74
  };
73
75
  }
74
76
 
@@ -38,6 +38,20 @@ export const AI_ERROR_RETRY = {
38
38
  // be both, and registering the code here is what makes the override honoured: core reads an
39
39
  // instance `terminal` on an UNREGISTERED code as unclassified.
40
40
  X_AI_PROVIDER_UNAVAILABLE: 'retryable',
41
+ // The two stops an unattended `agent()` run makes, REGISTERED terminal — the deliberate decision
42
+ // the paragraph above leaves to whoever makes it. A run stopped for repeating itself repeats
43
+ // itself again, and a run that outlived its declared deadline is the same run against the same
44
+ // deadline: unclassified, an `agentJob` with `attempts: 3` paid for it three times.
45
+ X_AGENT_TOOL_LOOP: 'terminal',
46
+ X_AGENT_DEADLINE: 'terminal',
47
+ // The four a job otherwise pays for once per attempt, for the same answer: the ceiling does not
48
+ // move between attempts, a schema two answers failed is a prompt to edit, arguments a model
49
+ // broke on this request are a schema to simplify, and a refused handle is refused again. The
50
+ // caller changes the declaration or starts a new run; retrying only re-bills.
51
+ X_LLM_TRUNCATED: 'terminal',
52
+ X_LLM_OUTPUT_INVALID: 'terminal',
53
+ X_LLM_TOOL_ARGUMENTS_INVALID: 'terminal',
54
+ X_AGENT_TRANSCRIPT_INVALID: 'terminal',
41
55
  } as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
42
56
 
43
57
  // `Partial`, so the table may be a subset — but every key is still checked against the declared set,
package/src/errors.ts CHANGED
@@ -30,6 +30,11 @@ export const AI_ERROR_CODES = [
30
30
  'X_VECTOR_UNSCOPED',
31
31
  'X_AI_CONTENT_UNSUPPORTED',
32
32
  'X_AI_MODEL_UNRESOLVED',
33
+ 'X_AGENT_TOOL_LOOP',
34
+ 'X_AGENT_DEADLINE',
35
+ 'X_LLM_TOOL_ARGUMENTS_INVALID',
36
+ 'X_AGENT_TRANSCRIPT_INVALID',
37
+ 'X_AI_PROVIDER_UNEXPECTED',
33
38
  ] as const;
34
39
 
35
40
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -48,7 +53,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
48
53
  X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
49
54
  X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
50
55
  X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
51
- X_AGENT_TOOL_UNEXPOSED: 'an agent lists a tool that is not an MCP-exposed action',
56
+ X_AGENT_TOOL_UNEXPOSED:
57
+ 'an agent lists a tool that is neither in-process nor an MCP-exposed action',
52
58
  X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
53
59
  X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
54
60
  X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
@@ -61,6 +67,12 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
61
67
  X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
62
68
  X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
63
69
  X_AI_MODEL_UNRESOLVED: 'a model call named no model',
70
+ X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
71
+ X_AGENT_DEADLINE: 'an agent ran past its declared deadline and was stopped',
72
+ X_LLM_TOOL_ARGUMENTS_INVALID: 'a finished turn called a tool with arguments that are not JSON',
73
+ X_AGENT_TRANSCRIPT_INVALID: 'an agent was asked to continue from a transcript it cannot read',
74
+ X_AI_PROVIDER_UNEXPECTED:
75
+ 'a provider ended a turn with an unknown reason and an incomplete answer',
64
76
  };
65
77
 
66
78
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
@@ -202,9 +214,20 @@ export class LlmStreamInvalidError extends UltimateError {
202
214
  * bad roll — a third attempt only spends money, so this throws instead of looping.
203
215
  */
204
216
  export class LlmOutputInvalidError extends UltimateError {
205
- constructor(input: { prompt: string; attempts: number; issues: string }) {
217
+ constructor(input: {
218
+ prompt: string;
219
+ attempts: number;
220
+ issues: string;
221
+ /** The last answer's issues, one per entry — what a job stores; `issues` is the cause's text. */
222
+ list?: readonly string[];
223
+ }) {
206
224
  super({
207
225
  code: 'X_LLM_OUTPUT_INVALID',
226
+ meta: {
227
+ prompt: input.prompt,
228
+ attempts: input.attempts,
229
+ issues: input.list ?? [input.issues],
230
+ },
208
231
  cause:
209
232
  `prompt "${input.prompt}" returned output failing its schema on all ` +
210
233
  `${input.attempts} attempts: ${input.issues}`,
@@ -250,7 +273,7 @@ export class AgentToolUnexposedError extends UltimateError {
250
273
  super({
251
274
  code: 'X_AGENT_TOOL_UNEXPOSED',
252
275
  cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
253
- fix: 'add mcp: { expose: true } to the action named in cause, or drop it from the agent tools list',
276
+ fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: true } to the action named in cause to offer it to MCP clients too, or drop it from the agent's tools",
254
277
  });
255
278
  }
256
279
  }
@@ -291,22 +314,6 @@ export class LlmRefusedError extends UltimateError {
291
314
  }
292
315
  }
293
316
 
294
- /**
295
- * The answer was cut off at the enforced ceiling and what arrived does not satisfy the schema.
296
- * Thrown instead of the repair turn on purpose: the ceiling does not move between attempts, so
297
- * a second answer truncates at exactly the same place and only spends money.
298
- */
299
- export class LlmTruncatedError extends UltimateError {
300
- constructor(input: { prompt: string; maxTokens: number }) {
301
- super({
302
- code: 'X_LLM_TRUNCATED',
303
- cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
304
- fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
305
- });
306
- }
307
- }
308
-
309
- /** A prompt was requested at a version whose content hash does not match the registry. */
310
317
  export class AiPromptVersionError extends UltimateError {
311
318
  constructor(input: { id: string; requested: string; available: readonly string[] }) {
312
319
  super({
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Logger } from '@ultimat3/core';
6
6
  import { hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
7
- import { statusFor } from '@ultimat3/http';
7
+ import { declaredStatusFor, statusFor } from '@ultimat3/http';
8
8
 
9
9
  /**
10
10
  * What may be shown. `code` is absent for an uncoded throw — a foreign `.message` is the server's
@@ -46,6 +46,42 @@ export function discloseFailure(
46
46
  };
47
47
  }
48
48
 
49
+ /**
50
+ * The verdict for a tool that runs IN PROCESS: the app's own code, called by the agent it was
51
+ * written for. Its typed refusal — an `UltimateError` built in this process, under a code NOBODY
52
+ * gave an HTTP status — is shown whole: code, cause, and the fix (the caller's when it has one).
53
+ * That is the one case the general rule gets wrong for this reader: an unmapped app code defaults
54
+ * to 500, a 500's cause is hidden, and a model that cannot read why its call was refused makes
55
+ * the same call again.
56
+ *
57
+ * Everything else keeps the general verdict, so nothing internal rides along: an uncoded throw
58
+ * (a `TypeError`, a driver's own error) shows nothing; a code the framework or the app DECLARED a
59
+ * status for follows that status — a driver's `X_DB_STATEMENT_FAILED` stays hidden; and a
60
+ * look-alike object carrying a `code` is not a typed refusal. Never a stack: only authored fields.
61
+ */
62
+ export function discloseOwnFailure(
63
+ error: unknown,
64
+ logger: Logger | undefined,
65
+ where: string,
66
+ ): DisclosedFailure {
67
+ const code = isUltimateError(error) ? stringField(error, 'code') : undefined;
68
+ // The model is a REMOTE reader, so the fix it gets is the caller's wherever one was written —
69
+ // `x actions show …` is no use to it.
70
+ const callerFix = code === undefined ? undefined : stringField(error, 'callerFix');
71
+ if (code === undefined || declaredStatusFor(code) !== undefined) {
72
+ const shown = discloseFailure(error, logger, where);
73
+ return shown.cause === undefined || callerFix === undefined
74
+ ? shown
75
+ : { ...shown, fix: callerFix };
76
+ }
77
+ const fix = callerFix ?? stringField(error, 'fix');
78
+ return {
79
+ code,
80
+ cause: stringField(error, 'cause') ?? 'unknown',
81
+ fix: fix === undefined || fix === '' ? undefined : fix,
82
+ };
83
+ }
84
+
49
85
  /**
50
86
  * A stack's FRAMES and nothing else: every line that is a frame (` at …`), none that is not.
51
87
  * The message is the first line — and, when it spans several, the lines after it until the first