@ultimat3/ai 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // Single responsibility: what a provider's own failure body says, and the credential that must
2
+ // never survive into it.
3
+ //
4
+ // Shared by both transports rather than copied: every endpoint this package speaks to reports a
5
+ // failure in the same `{ error: { message } }` envelope, and every one of them carries a key in a
6
+ // header a proxy can echo into its own 4xx body. Two copies of either rule is two behaviours to
7
+ // keep in step — and the scrub was on one provider only until 2026-08.
8
+
9
+ import { REDACTED } from '@ultimat3/core';
10
+
11
+ /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
12
+ const DETAIL_LIMIT = 300;
13
+
14
+ /**
15
+ * The provider's own message, when it sent one — it names the offending field, we name the fix.
16
+ * Falls back to the raw text because a proxy or a gateway timeout page is not JSON and is still
17
+ * the best evidence there is.
18
+ */
19
+ export async function detailOf(response: Response): Promise<string> {
20
+ const body = await response.text().catch(() => '');
21
+ try {
22
+ const parsed: unknown = JSON.parse(body);
23
+ if (typeof parsed === 'object' && parsed !== null) {
24
+ const error = (parsed as Record<string, unknown>)['error'];
25
+ if (typeof error === 'object' && error !== null) {
26
+ const message = (error as Record<string, unknown>)['message'];
27
+ if (typeof message === 'string') return message.slice(0, DETAIL_LIMIT);
28
+ }
29
+ }
30
+ } catch {
31
+ // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
32
+ }
33
+ return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
34
+ }
35
+
36
+ /**
37
+ * Every occurrence of the credential replaced with `[redacted]`. Cheap, and the one leak path:
38
+ * a proxy that echoes the request headers into its own 400 body puts the key in an error, and an
39
+ * error reaches a log index, a span and an HTTP problem document.
40
+ */
41
+ export function withoutKey(detail: string, apiKey: string): string {
42
+ if (apiKey === '') return detail;
43
+ return detail.split(apiKey).join(REDACTED);
44
+ }
package/src/errors.ts CHANGED
@@ -10,9 +10,15 @@ export const AI_ERROR_CODES = [
10
10
  'X_AI_BUDGET_EXCEEDED',
11
11
  'X_AI_GATEWAY_MISSING',
12
12
  'X_AI_PROMPT_VERSION',
13
+ 'X_AI_MODEL_UNKNOWN',
14
+ 'X_AI_PROMPT_SECRET',
13
15
  'X_LLM_OUTPUT_INVALID',
14
16
  'X_LLM_REFUSED',
15
17
  'X_LLM_TRUNCATED',
18
+ 'X_LLM_STREAM_INVALID',
19
+ 'X_AGENT_MAX_TURNS',
20
+ 'X_AGENT_TOOL_UNEXPOSED',
21
+ 'X_HIVE_EMPTY',
16
22
  'X_EVAL_THRESHOLD',
17
23
  'X_EVAL_BASELINE_MISSING',
18
24
  'X_EVAL_BASELINE_INVALID',
@@ -32,9 +38,15 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
32
38
  X_AI_BUDGET_EXCEEDED: 'a model call would exceed its budget',
33
39
  X_AI_GATEWAY_MISSING: 'an llm() action ran with no gateway installed',
34
40
  X_AI_PROMPT_VERSION: 'prompt version or slots are wrong',
41
+ X_AI_MODEL_UNKNOWN: 'a model id nothing registered in the catalogue',
42
+ X_AI_PROMPT_SECRET: 'a Secret was about to be rendered into a prompt',
35
43
  X_LLM_OUTPUT_INVALID: 'structured output failed its schema on the answer and the repair turn',
36
44
  X_LLM_REFUSED: 'the model declined the request',
37
45
  X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
46
+ X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
47
+ X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
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',
38
50
  X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
39
51
  X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
40
52
  X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
@@ -108,6 +120,63 @@ export class AiGatewayMissingError extends UltimateError {
108
120
  }
109
121
  }
110
122
 
123
+ /**
124
+ * A model id nothing put in the catalogue. This is what replaced the closed `ModelId` union: the
125
+ * union made a company's own model id inexpressible, so the only way past `tsc` was to claim a
126
+ * Claude id — and then `costOf` priced an internal model at Anthropic list rates and the budget
127
+ * ledger reserved against a number belonging to a model nobody ran. A wrong id is still refused;
128
+ * it is refused HERE, at the first read of the spec, instead of by making a right one impossible.
129
+ */
130
+ export class AiModelUnknownError extends UltimateError {
131
+ constructor(input: { model: string; registered: readonly string[] }) {
132
+ super({
133
+ code: 'X_AI_MODEL_UNKNOWN',
134
+ cause:
135
+ `model "${input.model}" has no registered spec, so nothing can price it ` +
136
+ `(registered: ${input.registered.length > 0 ? input.registered.join(', ') : 'none'})`,
137
+ // The `errors` gate blanks every interpolation, so the literal half alone has to name the
138
+ // call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
139
+ fix: 'registerModel({ id, contextWindow, maxOutput, inputPerMillion, outputPerMillion, cacheMinimumTokens, reasoning }) at boot, before configureAi',
140
+ docs: docsFor('X_AI_MODEL_UNKNOWN'),
141
+ meta: { model: input.model },
142
+ });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * A `Secret` reached a prompt variable. `Secret` redacts by VALUE, so this would not have leaked
148
+ * — it would have rendered `[redacted]` into the template and asked the model to reason about it,
149
+ * which is a prompt that reads fine and means something else. The same class of failure as an
150
+ * unfilled `{{slot}}`, and refused for the same reason: loudly, before a token is spent.
151
+ */
152
+ export class AiPromptSecretError extends UltimateError {
153
+ constructor(input: { ref: string; keys: readonly string[] }) {
154
+ super({
155
+ code: 'X_AI_PROMPT_SECRET',
156
+ cause: `prompt "${input.ref}" was given a Secret in vars(): ${input.keys.join(', ')}`,
157
+ fix: 'drop the key from vars() and from the template, or revealSecret(value) in vars() if the model genuinely has to read it',
158
+ docs: docsFor('X_AI_PROMPT_SECRET'),
159
+ });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * A streamed answer did not satisfy its `output` schema. Distinct from `X_LLM_OUTPUT_INVALID`
165
+ * because there is no repair turn to have failed: the consumer has already read the tokens, and
166
+ * replaying a second answer over the top is two answers to one question. So a stream gets one
167
+ * attempt, and the fix is either a looser schema or the non-streaming call that CAN repair.
168
+ */
169
+ export class LlmStreamInvalidError extends UltimateError {
170
+ constructor(input: { prompt: string; issues: string }) {
171
+ super({
172
+ code: 'X_LLM_STREAM_INVALID',
173
+ cause: `streamed answer to prompt "${input.prompt}" failed its output schema: ${input.issues}`,
174
+ fix: 'call the action instead of .stream() when the answer must satisfy a structured schema — a stream has already delivered its tokens and cannot take a repair turn',
175
+ docs: docsFor('X_LLM_STREAM_INVALID'),
176
+ });
177
+ }
178
+ }
179
+
111
180
  /**
112
181
  * The model's answer failed the action's `output` schema on the first turn AND on the repair
113
182
  * turn that followed. Two failures is a disagreement between the prompt and the schema, not a
@@ -126,6 +195,50 @@ export class LlmOutputInvalidError extends UltimateError {
126
195
  }
127
196
  }
128
197
 
198
+ /**
199
+ * An `agent()` ran out of turns with no answer. Never a partial one: the loop's whole contract is
200
+ * that it either satisfies `output` or says it did not, and a half-finished transcript returned as
201
+ * a result is a model's working notes presented as a decision.
202
+ *
203
+ * Reaching the ceiling almost always means the loop has no exit condition — a tool that answers
204
+ * the same thing every turn, or a prompt that never tells the model to finish. Raising the
205
+ * ceiling on that spends more money on the same non-answer, which is why the fix names the prompt
206
+ * before it names the number.
207
+ */
208
+ export class AgentMaxTurnsError extends UltimateError {
209
+ constructor(input: { agent: string; turns: number; calls: number }) {
210
+ super({
211
+ code: 'X_AGENT_MAX_TURNS',
212
+ cause:
213
+ `agent "${input.agent}" used all ${input.turns} turns and ${input.calls} tool calls ` +
214
+ `without calling the respond tool`,
215
+ fix: 'tell the template when to stop and answer through the respond tool, then bump its version — raise maxTurns only once the run demonstrably converges',
216
+ docs: docsFor('X_AGENT_MAX_TURNS'),
217
+ meta: { agent: input.agent, turns: input.turns },
218
+ });
219
+ }
220
+ }
221
+
222
+ /**
223
+ * An `agent()` lists an action that is not an MCP-exposed tool. Refused at DECLARATION rather
224
+ * than filtered at the call, because a silently dropped tool is the worst of both: the
225
+ * declaration reads as if the model can call it, and the model is never offered it.
226
+ *
227
+ * `isMcpExposed` is the one predicate — an in-app agent and an external MCP client see exactly
228
+ * the same catalogue, which is what keeps "there is no second authz system" true of the catalogue
229
+ * too.
230
+ */
231
+ export class AgentToolUnexposedError extends UltimateError {
232
+ constructor(input: { agent: string; tools: readonly string[] }) {
233
+ super({
234
+ code: 'X_AGENT_TOOL_UNEXPOSED',
235
+ cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
236
+ fix: 'add mcp: { expose: true } to the action named in cause, or drop it from the agent tools list',
237
+ docs: docsFor('X_AGENT_TOOL_UNEXPOSED'),
238
+ });
239
+ }
240
+ }
241
+
129
242
  /**
130
243
  * The provider's safety classifiers declined the request. A refusal is a 200 with no answer in
131
244
  * it, so it has to become an error HERE or it becomes an empty string somewhere downstream that
@@ -137,8 +250,13 @@ export class LlmRefusedError extends UltimateError {
137
250
  constructor(input: {
138
251
  prompt: string;
139
252
  model: string;
140
- /** A blessed model that is NOT the one that refused — the fix has to be pasteable. */
141
- alternative: string;
253
+ /**
254
+ * A blessed model MORE capable than the one that refused, or `undefined` when the refusal
255
+ * came from the most capable one this build knows. Retrying a refusal on a weaker model is
256
+ * the one retry that cannot help, so the fix line drops the suggestion rather than inventing
257
+ * a downgrade.
258
+ */
259
+ alternative: string | undefined;
142
260
  category: string | undefined;
143
261
  explanation: string | undefined;
144
262
  }) {
@@ -148,7 +266,10 @@ export class LlmRefusedError extends UltimateError {
148
266
  `model "${input.model}" declined prompt "${input.prompt}"` +
149
267
  `${input.category === undefined ? '' : ` (${input.category})`}` +
150
268
  `${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
151
- fix: `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
269
+ fix:
270
+ input.alternative === undefined
271
+ ? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
272
+ : `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
152
273
  docs: docsFor('X_LLM_REFUSED'),
153
274
  meta: { model: input.model, category: input.category },
154
275
  });
@@ -201,100 +322,15 @@ export class AiPromptRenderError extends UltimateError {
201
322
  }
202
323
  }
203
324
 
204
- /**
205
- * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
206
- * DROP, not an absolute number — a model that got marginally worse everywhere is not the same
207
- * event as a prompt edit that broke one case, and only the second one is anybody's fault.
208
- *
209
- * This is a test failure, not a warning.
210
- */
211
- export class EvalThresholdError extends UltimateError {
212
- constructor(input: {
213
- eval: string;
214
- score: number;
215
- baseline: number;
216
- tolerance: number;
217
- promptVersion: string;
218
- regressed: readonly string[];
219
- }) {
220
- super({
221
- code: 'X_EVAL_THRESHOLD',
222
- cause:
223
- `eval "${input.eval}" scored ${input.score.toFixed(3)} against a recorded baseline of ` +
224
- `${input.baseline.toFixed(3)} (tolerance ${input.tolerance.toFixed(3)}) on prompt ` +
225
- `version ${input.promptVersion}; regressed: ${input.regressed.join(', ')}`,
226
- fix: `x test ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
227
- docs: docsFor('X_EVAL_THRESHOLD'),
228
- });
229
- }
230
- }
231
-
232
- /**
233
- * An eval declared a baseline that has never been recorded. Not a pass: an eval with nothing to
234
- * compare against gates on nothing, and a step that cannot fail is a step that is not running.
235
- */
236
- export class EvalBaselineMissingError extends UltimateError {
237
- constructor(input: { eval: string; path: string; reason: string; fix?: string }) {
238
- super({
239
- code: 'X_EVAL_BASELINE_MISSING',
240
- cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
241
- fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
242
- docs: docsFor('X_EVAL_BASELINE_MISSING'),
243
- });
244
- }
245
- }
246
-
247
- /** A recorded baseline that cannot be read. Never treated as absent — that would erase a gate. */
248
- export class EvalBaselineInvalidError extends UltimateError {
249
- constructor(input: { path: string; problem: string }) {
250
- super({
251
- code: 'X_EVAL_BASELINE_INVALID',
252
- cause: `the recorded baseline ${input.path} ${input.problem}`,
253
- fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
254
- docs: docsFor('X_EVAL_BASELINE_INVALID'),
255
- });
256
- }
257
- }
258
-
259
- /**
260
- * A registered prompt that no eval names. An unevaluated prompt is untested code that costs
261
- * money and answers users, so the gate fails on it exactly like an untyped module.
262
- */
263
- export class EvalMissingError extends UltimateError {
264
- constructor(input: { prompt: string; id: string }) {
265
- super({
266
- code: 'X_EVAL_MISSING',
267
- cause: `prompt "${input.prompt}" has no eval`,
268
- fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
269
- docs: docsFor('X_EVAL_MISSING'),
270
- });
271
- }
272
- }
273
-
274
- /**
275
- * The gate ran with baseline recording switched on. Recording makes every eval write the numbers
276
- * it just measured and pass, so a `x verify` that inherited the flag reports green over scores
277
- * nothing compared — and rewrites the committed baselines on its way through, which is the half
278
- * a red step alone would not undo. Recording is a deliberate, reviewable diff, never a gate run.
279
- */
280
- export class EvalRecordingError extends UltimateError {
281
- constructor(input: { env: string }) {
282
- super({
283
- code: 'X_EVAL_RECORDING',
284
- cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
285
- fix: `env -u ${input.env} x verify`,
286
- docs: docsFor('X_EVAL_RECORDING'),
287
- });
288
- }
289
- }
290
-
291
325
  /** A vector's length does not match the store's declared dimension. */
292
326
  export class VectorDimMismatchError extends UltimateError {
293
327
  constructor(input: { store: string; expected: number; received: number }) {
294
328
  super({
295
329
  code: 'X_VECTOR_DIM_MISMATCH',
296
330
  cause: `store "${input.store}" expects ${input.expected} dimensions, got ${input.received}`,
297
- fix: 'use the same embedder that created the store, or x ai reindex to rebuild it',
331
+ // Not `x ai reindex`: that command is PLANNED and throws, so a fix line naming it sends an
332
+ // operator to a wall. A fix has to be performable today, which here means app code.
333
+ fix: 'use the same embedder that created the store, or re-embed every record at the new width and upsert it',
298
334
  docs: docsFor('X_VECTOR_DIM_MISMATCH'),
299
335
  });
300
336
  }
@@ -330,7 +366,7 @@ export class EmbedderDimMismatchError extends UltimateError {
330
366
  cause:
331
367
  `embedder "${input.embedder}" is declared with ${input.expected} dimensions but the ` +
332
368
  `provider returned ${input.received}`,
333
- fix: `set dimension: ${input.received} on the embedder, then x ai reindex to rebuild the store`,
369
+ fix: `set dimension: ${input.received} on the embedder, then re-embed every record at that width and upsert it`,
334
370
  docs: docsFor('X_VECTOR_DIM_MISMATCH'),
335
371
  });
336
372
  }
@@ -393,13 +429,23 @@ export class AiRequestInvalidError extends UltimateError {
393
429
  export class AiTransportError extends UltimateError {
394
430
  readonly status: number | undefined;
395
431
 
396
- constructor(input: { provider: string; status?: number | undefined; detail: string }) {
432
+ constructor(input: {
433
+ provider: string;
434
+ status?: number | undefined;
435
+ detail: string;
436
+ /**
437
+ * The env var holding THIS provider's key. Passed on the HTTP path, where a 401 has one fix:
438
+ * a hardcoded `ANTHROPIC_API_KEY` was the whole fix line, so an OpenAI-format endpoint
439
+ * rejecting a key sent an operator to set a variable it never reads.
440
+ */
441
+ envVar?: string | undefined;
442
+ }) {
397
443
  super({
398
444
  code: 'X_AI_PROVIDER_UNAVAILABLE',
399
445
  cause: `provider "${input.provider}" ${
400
446
  input.status === undefined ? 'failed' : `returned ${input.status}`
401
447
  }: ${input.detail}`,
402
- fix: fixForStatus(input.status),
448
+ fix: fixForStatus(input.status, input.envVar),
403
449
  docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
404
450
  meta: { provider: input.provider, status: input.status },
405
451
  });
@@ -407,9 +453,11 @@ export class AiTransportError extends UltimateError {
407
453
  }
408
454
  }
409
455
 
410
- function fixForStatus(status: number | undefined): string {
456
+ function fixForStatus(status: number | undefined, envVar: string | undefined): string {
411
457
  if (status === 401 || status === 403) {
412
- return 'export ANTHROPIC_API_KEY=<key> with a key that is active for this model';
458
+ return envVar === undefined
459
+ ? 'export the API key env var of the provider named in cause, with a key that is active for this model'
460
+ : `export ${envVar}=<key> with a key that is active for this model`;
413
461
  }
414
462
  if (status === 429) {
415
463
  return 'lower concurrency or raise the provider rate limit; the gateway already backs off';
@@ -9,7 +9,7 @@
9
9
  // not a measurement.
10
10
 
11
11
  import { isAbsolute } from 'node:path';
12
- import { EvalBaselineInvalidError, EvalBaselineMissingError } from './errors';
12
+ import { EvalBaselineInvalidError, EvalBaselineMissingError } from './eval-errors';
13
13
 
14
14
  export interface EvalBaseline {
15
15
  readonly eval: string;
@@ -0,0 +1,98 @@
1
+ // The five X_EVAL_* codes, apart from ./errors only because one file has one job and the catalogue
2
+ // outgrew its ceiling. The codes themselves, their titles and the single `registerErrorCodes` call
3
+ // stay in ./errors — one owner, one registration, one place a duplicate can surface.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import type { AiErrorCode } from './errors';
7
+
8
+ const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
9
+
10
+ /**
11
+ * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
12
+ * DROP, not an absolute number — a model that got marginally worse everywhere is not the same
13
+ * event as a prompt edit that broke one case, and only the second one is anybody's fault.
14
+ *
15
+ * This is a test failure, not a warning.
16
+ */
17
+ export class EvalThresholdError extends UltimateError {
18
+ constructor(input: {
19
+ eval: string;
20
+ score: number;
21
+ baseline: number;
22
+ tolerance: number;
23
+ promptVersion: string;
24
+ regressed: readonly string[];
25
+ }) {
26
+ super({
27
+ code: 'X_EVAL_THRESHOLD',
28
+ cause:
29
+ `eval "${input.eval}" scored ${input.score.toFixed(3)} against a recorded baseline of ` +
30
+ `${input.baseline.toFixed(3)} (tolerance ${input.tolerance.toFixed(3)}) on prompt ` +
31
+ `version ${input.promptVersion}; regressed: ${input.regressed.join(', ')}`,
32
+ // `x test eval --filter <name>`, never `x test <name>`: `x test`'s positional is a TestType,
33
+ // so the eval's own name there is `X_CLI_BAD_FLAG` ("not a test type") — a fix line that
34
+ // cannot be run is axiom 4 broken at the one moment it is needed.
35
+ fix: `x test eval --filter ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
36
+ docs: docsFor('X_EVAL_THRESHOLD'),
37
+ });
38
+ }
39
+ }
40
+
41
+ /**
42
+ * An eval declared a baseline that has never been recorded. Not a pass: an eval with nothing to
43
+ * compare against gates on nothing, and a step that cannot fail is a step that is not running.
44
+ */
45
+ export class EvalBaselineMissingError extends UltimateError {
46
+ constructor(input: { eval: string; path: string; reason: string; fix?: string }) {
47
+ super({
48
+ code: 'X_EVAL_BASELINE_MISSING',
49
+ cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
50
+ fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
51
+ docs: docsFor('X_EVAL_BASELINE_MISSING'),
52
+ });
53
+ }
54
+ }
55
+
56
+ /** A recorded baseline that cannot be read. Never treated as absent — that would erase a gate. */
57
+ export class EvalBaselineInvalidError extends UltimateError {
58
+ constructor(input: { path: string; problem: string }) {
59
+ super({
60
+ code: 'X_EVAL_BASELINE_INVALID',
61
+ cause: `the recorded baseline ${input.path} ${input.problem}`,
62
+ fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
63
+ docs: docsFor('X_EVAL_BASELINE_INVALID'),
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A registered prompt that no eval names. An unevaluated prompt is untested code that costs
70
+ * money and answers users, so the gate fails on it exactly like an untyped module.
71
+ */
72
+ export class EvalMissingError extends UltimateError {
73
+ constructor(input: { prompt: string; id: string }) {
74
+ super({
75
+ code: 'X_EVAL_MISSING',
76
+ cause: `prompt "${input.prompt}" has no eval`,
77
+ fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
78
+ docs: docsFor('X_EVAL_MISSING'),
79
+ });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The gate ran with baseline recording switched on. Recording makes every eval write the numbers
85
+ * it just measured and pass, so a `x verify` that inherited the flag reports green over scores
86
+ * nothing compared — and rewrites the committed baselines on its way through, which is the half
87
+ * a red step alone would not undo. Recording is a deliberate, reviewable diff, never a gate run.
88
+ */
89
+ export class EvalRecordingError extends UltimateError {
90
+ constructor(input: { env: string }) {
91
+ super({
92
+ code: 'X_EVAL_RECORDING',
93
+ cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
94
+ fix: `env -u ${input.env} x verify`,
95
+ docs: docsFor('X_EVAL_RECORDING'),
96
+ });
97
+ }
98
+ }
package/src/evals.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  // Every result is filed against a prompt's content hash, so a score is always attributable
12
12
  // to an exact prompt rather than "whatever was in main that day".
13
13
 
14
- import { EvalBaselineMissingError, EvalThresholdError } from './errors';
15
14
  import type { EvalBaseline, Regression } from './eval-baseline';
16
15
  import {
17
16
  baselinePath,
@@ -21,6 +20,7 @@ import {
21
20
  regressionsAgainst,
22
21
  writeBaseline,
23
22
  } from './eval-baseline';
23
+ import { EvalBaselineMissingError, EvalThresholdError } from './eval-errors';
24
24
  import type { Gateway } from './gateway';
25
25
  import type { Prompt, PromptVars } from './prompt';
26
26
  import { describePrompts } from './prompt';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The eval attached to `fixLinePrompt`. Cases live here rather than in the test file, matching
3
+ * every app's own prompts — a fixture-driven, deterministic `exact` scorer, no judge, because
4
+ * this is a proof of the eval mechanism itself and a proof that can drift is not one.
5
+ */
6
+
7
+ import { defineEval } from './evals';
8
+ import { fixLinePrompt } from './fix-line';
9
+ import { exact } from './scorers';
10
+
11
+ export const fixLineCases = [
12
+ {
13
+ name: 'runnable/x-command',
14
+ vars: { fixLine: 'bun run scripts/verify.ts --only lint,boundaries' },
15
+ expected: 'runnable',
16
+ },
17
+ { name: 'runnable/cli-flag', vars: { fixLine: 'x db migrate' }, expected: 'runnable' },
18
+ {
19
+ name: 'vague/check',
20
+ vars: { fixLine: 'check the configuration and try again' },
21
+ expected: 'vague',
22
+ },
23
+ { name: 'vague/see-docs', vars: { fixLine: 'see the docs for details' }, expected: 'vague' },
24
+ ];
25
+
26
+ export const fixLineEval = defineEval({
27
+ name: 'ai.fix-line-runnable',
28
+ prompt: fixLinePrompt,
29
+ // The gate is the drop from this recorded score, never an absolute number: models drift,
30
+ // prompts should not. Accepting a new number is a diff in the committed baseline file.
31
+ baseline: import.meta.resolve('./fix-line.v1.baseline.json'),
32
+ tolerance: 0.05,
33
+ scorers: [exact],
34
+ cases: fixLineCases,
35
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The prompt behind `fix-line` — `@ultimat3/ai`'s own dogfood eval, and the package's first
3
+ * framework-level `*.eval.test.ts`. Not an app feature: every app that declares a prompt is
4
+ * required to pair it with an eval and a committed baseline (`defineEval`, `X_EVAL_MISSING`,
5
+ * `X_EVAL_BASELINE_MISSING`), and this proves that whole convention actually catches a
6
+ * regression from inside the package that owns it — through the real opt-in suite `x verify`'s
7
+ * `eval` step runs, not only through `evals.test.ts`'s unit fixtures against a temp-dir baseline.
8
+ *
9
+ * The task is small on purpose: classify whether an error's `fix:` line is a runnable command —
10
+ * axiom 4, "errors are instructions" — or vague guidance ("check the config", "see the docs").
11
+ */
12
+
13
+ import { definePrompt } from './prompt';
14
+
15
+ export const fixLinePrompt = definePrompt<{ fixLine: string }>({
16
+ id: 'ai.fix-line-runnable',
17
+ version: '1',
18
+ template:
19
+ 'Reply with exactly one word: "runnable" if the fix line below names a command to run, or ' +
20
+ '"vague" if it only describes what to do without naming one.\nFix line: {{fixLine}}',
21
+ input: {
22
+ type: 'object',
23
+ properties: { fixLine: { type: 'string' } },
24
+ required: ['fixLine'],
25
+ },
26
+ output: { type: 'string', enum: ['runnable', 'vague'] },
27
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "eval": "ai.fix-line-runnable",
3
+ "prompt": "ai.fix-line-runnable@1",
4
+ "promptHash": "6b788c934d335044043d13c71c1c7b49",
5
+ "score": 1,
6
+ "cases": {
7
+ "runnable/cli-flag": 1,
8
+ "runnable/x-command": 1,
9
+ "vague/check": 1,
10
+ "vague/see-docs": 1
11
+ }
12
+ }