@ultimat3/ai 29.4.0 → 30.1.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 +63 -90
- package/README.md +125 -8
- package/package.json +23 -11
- package/src/agent-checkpoint.ts +44 -0
- package/src/agent-compact-def.ts +11 -0
- package/src/agent-compact-measure.ts +38 -0
- package/src/agent-compact-request.ts +100 -0
- package/src/agent-compact-text.ts +95 -0
- package/src/agent-compact.ts +75 -50
- package/src/agent-continue.ts +15 -3
- package/src/agent-def.ts +9 -0
- package/src/agent-errors.ts +12 -2
- package/src/agent-facts.ts +8 -0
- package/src/agent-send.ts +82 -0
- package/src/agent-stream.ts +76 -0
- package/src/agent.ts +71 -21
- package/src/answer-errors.ts +1 -1
- package/src/anthropic-provider.ts +291 -0
- package/src/content-errors.ts +1 -1
- package/src/context-overflow.ts +86 -0
- package/src/error-codes.ts +95 -0
- package/src/errors-retry.ts +8 -4
- package/src/errors.ts +6 -87
- package/src/eval-agent.ts +100 -0
- package/src/eval-errors.ts +31 -5
- package/src/evals.ts +125 -45
- package/src/gateway-providers.ts +215 -0
- package/src/gateway.ts +63 -153
- package/src/hive-errors.ts +1 -1
- package/src/in-process-tool.ts +20 -11
- package/src/index.ts +31 -7
- package/src/llm.ts +39 -8
- package/src/model-resolve.ts +7 -1
- package/src/openai-body.ts +12 -5
- package/src/openai-provider.ts +28 -8
- package/src/openai-wire.ts +11 -7
- package/src/prompt-cache-rows.ts +82 -0
- package/src/prompt-cache.ts +24 -2
- package/src/provider.ts +15 -274
- package/src/remote-embedder.ts +38 -11
- package/src/run-meter.ts +40 -2
- package/src/runtime.ts +38 -3
- package/src/scorers.ts +35 -1
- package/src/scripted-provider.ts +146 -0
- package/src/tools-gate.ts +46 -0
- package/src/tools.ts +101 -27
- package/src/wire.ts +12 -4
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Single responsibility: telling a provider's "the prompt outgrew the window" refusal apart from
|
|
2
|
+
// every other non-2xx — the one predicate, the one table of phrases, and the class it raises.
|
|
3
|
+
// Both transports call `providerFailure`; the code and title stay in ./error-codes with the rest.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { BILLED_META, billedCall } from './billed';
|
|
7
|
+
import { costOf } from './cost';
|
|
8
|
+
import './error-codes';
|
|
9
|
+
import { AiTransportError } from './errors';
|
|
10
|
+
import type { ModelId } from './models';
|
|
11
|
+
import { ZERO_USAGE } from './wire';
|
|
12
|
+
|
|
13
|
+
/** The statuses a context-window refusal arrives with: 400 on every wire, 413 behind some proxies. */
|
|
14
|
+
const OVERFLOW_STATUSES: ReadonlySet<number> = new Set([400, 413]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a provider's message says when the request outgrew the model's window, lower-cased. One
|
|
18
|
+
* row per phrase as each endpoint words it — a phrase, never a status alone: a 400 is also a bad
|
|
19
|
+
* tool schema, and that one must stay `X_AI_PROVIDER_UNAVAILABLE` with its own fix line.
|
|
20
|
+
*/
|
|
21
|
+
export const CONTEXT_OVERFLOW_PHRASES: readonly string[] = [
|
|
22
|
+
// Anthropic: "prompt is too long: 210000 tokens > 200000 maximum".
|
|
23
|
+
'prompt is too long',
|
|
24
|
+
// Anthropic: "input length and `max_tokens` exceed context limit: …".
|
|
25
|
+
'exceed context limit',
|
|
26
|
+
// OpenAI, vLLM, OpenRouter: "This model's maximum context length is 128000 tokens. …".
|
|
27
|
+
'maximum context length',
|
|
28
|
+
// OpenAI's error code, which some proxies put in the message.
|
|
29
|
+
'context_length_exceeded',
|
|
30
|
+
// OpenAI Responses-era wording: "Your input exceeds the context window of this model."
|
|
31
|
+
'exceeds the context window',
|
|
32
|
+
// Mistral: "… too large for model with 32768 maximum context length".
|
|
33
|
+
'too large for model with',
|
|
34
|
+
// Gemini's OpenAI-compatible endpoint.
|
|
35
|
+
'exceeds the maximum number of tokens allowed',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Whether a provider's non-2xx answer is the request being longer than the model's window. */
|
|
39
|
+
export function isContextOverflow(status: number | undefined, detail: string): boolean {
|
|
40
|
+
if (status === undefined || !OVERFLOW_STATUSES.has(status)) return false;
|
|
41
|
+
const said = detail.toLowerCase();
|
|
42
|
+
return CONTEXT_OVERFLOW_PHRASES.some((phrase) => said.includes(phrase));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The provider refused the request as over the model's context window. Its own code, not the
|
|
47
|
+
* transport's: the transport code is `retryable`, and every retry of this one re-sends the same
|
|
48
|
+
* request to the same refusal. A refused request billed nothing, and says so on `meta.billed` so
|
|
49
|
+
* a run's meter settles it instead of counting a call whose bill it cannot know.
|
|
50
|
+
*/
|
|
51
|
+
export class AiContextOverflowError extends UltimateError {
|
|
52
|
+
constructor(input: {
|
|
53
|
+
readonly provider: string;
|
|
54
|
+
readonly model: ModelId;
|
|
55
|
+
readonly status: number;
|
|
56
|
+
readonly detail: string;
|
|
57
|
+
}) {
|
|
58
|
+
super({
|
|
59
|
+
code: 'X_AI_CONTEXT_OVERFLOW',
|
|
60
|
+
cause: `provider "${input.provider}" refused a request to model "${input.model}" as longer than its context window (${input.status}): ${input.detail}`,
|
|
61
|
+
fix: 'compact: { at: 0.6, keepTurns: 2 } # on the agent — or shorten the prompt, or route to a model whose registerModel row has a larger contextWindow',
|
|
62
|
+
meta: {
|
|
63
|
+
provider: input.provider,
|
|
64
|
+
model: input.model,
|
|
65
|
+
status: input.status,
|
|
66
|
+
detail: input.detail,
|
|
67
|
+
[BILLED_META]: billedCall(ZERO_USAGE, costOf(input.model, ZERO_USAGE)),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The error a provider's non-2xx answer becomes: an overflow when it says so, else the transport's. */
|
|
74
|
+
export function providerFailure(input: {
|
|
75
|
+
readonly provider: string;
|
|
76
|
+
readonly model: ModelId;
|
|
77
|
+
readonly status: number;
|
|
78
|
+
/** Already scrubbed of the credential (`withoutKey`). */
|
|
79
|
+
readonly detail: string;
|
|
80
|
+
readonly envVar: string;
|
|
81
|
+
}): UltimateError {
|
|
82
|
+
if (isContextOverflow(input.status, input.detail)) {
|
|
83
|
+
return new AiContextOverflowError(input);
|
|
84
|
+
}
|
|
85
|
+
return new AiTransportError(input);
|
|
86
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The X_* codes owned by @ultimat3/ai, and nothing else: the list, its titles and the one
|
|
2
|
+
// registration call. `bun run new-error-code --package ai` writes here. The classes that throw
|
|
3
|
+
// these codes live in `./errors` and its siblings; `./errors` imports this file for its
|
|
4
|
+
// registration, so a module that can construct one of them has the code registered.
|
|
5
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export const AI_ERROR_CODES = [
|
|
8
|
+
'X_AI_PROVIDER_UNAVAILABLE',
|
|
9
|
+
'X_AI_KEY_MISSING',
|
|
10
|
+
'X_AI_REQUEST_INVALID',
|
|
11
|
+
'X_AI_BUDGET_EXCEEDED',
|
|
12
|
+
'X_AI_GATEWAY_MISSING',
|
|
13
|
+
'X_AI_PROMPT_VERSION',
|
|
14
|
+
'X_AI_MODEL_UNKNOWN',
|
|
15
|
+
'X_AI_PROMPT_SECRET',
|
|
16
|
+
'X_LLM_OUTPUT_INVALID',
|
|
17
|
+
'X_LLM_REFUSED',
|
|
18
|
+
'X_LLM_TRUNCATED',
|
|
19
|
+
'X_LLM_STREAM_INVALID',
|
|
20
|
+
'X_AGENT_MAX_TURNS',
|
|
21
|
+
'X_AGENT_TOOL_UNEXPOSED',
|
|
22
|
+
'X_HIVE_EMPTY',
|
|
23
|
+
'X_EVAL_THRESHOLD',
|
|
24
|
+
'X_EVAL_BASELINE_MISSING',
|
|
25
|
+
'X_EVAL_BASELINE_INVALID',
|
|
26
|
+
'X_EVAL_MISSING',
|
|
27
|
+
'X_EVAL_RECORDING',
|
|
28
|
+
'X_VECTOR_DIM_MISMATCH',
|
|
29
|
+
'X_VECTOR_SCOPE_WIDENED',
|
|
30
|
+
'X_AI_EMBEDDER_INVALID',
|
|
31
|
+
'X_VECTOR_UNSCOPED',
|
|
32
|
+
'X_AI_CONTENT_UNSUPPORTED',
|
|
33
|
+
'X_AI_MODEL_UNRESOLVED',
|
|
34
|
+
'X_AGENT_TOOL_LOOP',
|
|
35
|
+
'X_AGENT_DEADLINE',
|
|
36
|
+
'X_LLM_TOOL_ARGUMENTS_INVALID',
|
|
37
|
+
'X_AGENT_TRANSCRIPT_INVALID',
|
|
38
|
+
'X_AI_PROVIDER_UNEXPECTED',
|
|
39
|
+
'X_AGENT_TOOL_UNDECLARED',
|
|
40
|
+
'X_AGENT_CONTEXT_EXHAUSTED',
|
|
41
|
+
'X_AI_SCRIPT_EXHAUSTED',
|
|
42
|
+
'X_EVAL_TOOL_UNDOUBLED',
|
|
43
|
+
'X_AI_CONTEXT_OVERFLOW',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
47
|
+
|
|
48
|
+
export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
49
|
+
X_AI_PROVIDER_UNAVAILABLE: 'the model provider is unreachable',
|
|
50
|
+
X_AI_KEY_MISSING: 'the provider API key is not set',
|
|
51
|
+
X_AI_REQUEST_INVALID: 'the provider would reject this request',
|
|
52
|
+
X_AI_BUDGET_EXCEEDED: 'a model call would exceed its budget',
|
|
53
|
+
X_AI_GATEWAY_MISSING: 'an llm() action ran with no gateway installed',
|
|
54
|
+
X_AI_PROMPT_VERSION: 'prompt version or slots are wrong',
|
|
55
|
+
X_AI_MODEL_UNKNOWN: 'a model id nothing registered in the catalogue',
|
|
56
|
+
X_AI_PROMPT_SECRET: 'a Secret was about to be rendered into a prompt',
|
|
57
|
+
X_LLM_OUTPUT_INVALID: 'structured output failed its schema on the answer and the repair turn',
|
|
58
|
+
X_LLM_REFUSED: 'the model declined the request',
|
|
59
|
+
X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
|
|
60
|
+
X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
|
|
61
|
+
X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
|
|
62
|
+
X_AGENT_TOOL_UNEXPOSED:
|
|
63
|
+
'an agent lists a tool that is neither in-process nor an MCP-exposed action',
|
|
64
|
+
X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
|
|
65
|
+
X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
|
|
66
|
+
X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
|
|
67
|
+
X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
|
|
68
|
+
X_EVAL_MISSING: 'a prompt has no eval',
|
|
69
|
+
X_EVAL_RECORDING: 'the gate ran with baseline recording switched on',
|
|
70
|
+
X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
|
|
71
|
+
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
|
|
72
|
+
X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
|
|
73
|
+
X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
|
|
74
|
+
X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
|
|
75
|
+
X_AI_MODEL_UNRESOLVED: 'a model call named no model',
|
|
76
|
+
X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
|
|
77
|
+
X_AGENT_DEADLINE: 'an agent ran past its declared deadline and was stopped',
|
|
78
|
+
X_LLM_TOOL_ARGUMENTS_INVALID: 'a finished turn called a tool with arguments that are not JSON',
|
|
79
|
+
X_AGENT_TRANSCRIPT_INVALID: 'an agent was asked to continue from a transcript it cannot read',
|
|
80
|
+
X_AI_PROVIDER_UNEXPECTED:
|
|
81
|
+
'a provider ended a turn with an unknown reason and an incomplete answer',
|
|
82
|
+
X_AGENT_TOOL_UNDECLARED: 'an agent run offered a tool its declaration does not list',
|
|
83
|
+
X_AGENT_CONTEXT_EXHAUSTED: "an agent's next request would not fit its model's context window",
|
|
84
|
+
X_AI_SCRIPT_EXHAUSTED: 'a scriptedProvider was called more times than it has turns',
|
|
85
|
+
X_EVAL_TOOL_UNDOUBLED: 'an agent eval called a tool with no double',
|
|
86
|
+
X_AI_CONTEXT_OVERFLOW: 'a provider refused a request as longer than the model context window',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
|
90
|
+
// in one call: every code above is owned here, so a second package claiming one is a real conflict
|
|
91
|
+
// that has to surface as X_ERROR_CODE_DUPLICATE rather than resolve to whoever imported first.
|
|
92
|
+
// `X_FORBIDDEN` (policy's) and `X_CURRENCY_MISMATCH` (money's) are thrown here but never titled.
|
|
93
|
+
registerErrorCodes(
|
|
94
|
+
Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
95
|
+
);
|
package/src/errors-retry.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// The retry classification for this package's codes, apart from ./
|
|
2
|
-
// one job
|
|
3
|
-
//
|
|
1
|
+
// The retry classification for this package's codes, apart from ./error-codes only because one
|
|
2
|
+
// file has one job. The codes, their titles and the single `registerErrorCodes` call stay in
|
|
3
|
+
// ./error-codes — one owner, one registration.
|
|
4
4
|
|
|
5
5
|
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
6
|
import { registerErrorRetry } from '@ultimat3/core';
|
|
7
|
-
import type { AiErrorCode } from './
|
|
7
|
+
import type { AiErrorCode } from './error-codes';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* May a client run this model call again? Unclassified means `terminal` — core fails closed — and
|
|
@@ -58,6 +58,10 @@ export const AI_ERROR_RETRY = {
|
|
|
58
58
|
// A run whose transcript outgrew the window outgrows it again: the attempt starts from the same
|
|
59
59
|
// input, takes the same turns and reaches the same size. The fix is `compact` or the model.
|
|
60
60
|
X_AGENT_CONTEXT_EXHAUSTED: 'terminal',
|
|
61
|
+
// A provider refused the request as over the window: the same request overflows on every
|
|
62
|
+
// attempt and every provider. Was the transport code, `retryable`, until 2026-11 — an `agentJob`
|
|
63
|
+
// re-bought every turn of the run to reach the same 400 three times.
|
|
64
|
+
X_AI_CONTEXT_OVERFLOW: 'terminal',
|
|
61
65
|
} as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
|
|
62
66
|
|
|
63
67
|
// `Partial`, so the table may be a subset — but every key is still checked against the declared set,
|
package/src/errors.ts
CHANGED
|
@@ -1,91 +1,10 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// change, because the caller is often a CI job with no human attached.
|
|
1
|
+
// The error classes for @ultimat3/ai's core codes. Budget and threshold failures name the exact
|
|
2
|
+
// knob to change, because the caller is often a CI job with no human attached.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
'X_AI_KEY_MISSING',
|
|
9
|
-
'X_AI_REQUEST_INVALID',
|
|
10
|
-
'X_AI_BUDGET_EXCEEDED',
|
|
11
|
-
'X_AI_GATEWAY_MISSING',
|
|
12
|
-
'X_AI_PROMPT_VERSION',
|
|
13
|
-
'X_AI_MODEL_UNKNOWN',
|
|
14
|
-
'X_AI_PROMPT_SECRET',
|
|
15
|
-
'X_LLM_OUTPUT_INVALID',
|
|
16
|
-
'X_LLM_REFUSED',
|
|
17
|
-
'X_LLM_TRUNCATED',
|
|
18
|
-
'X_LLM_STREAM_INVALID',
|
|
19
|
-
'X_AGENT_MAX_TURNS',
|
|
20
|
-
'X_AGENT_TOOL_UNEXPOSED',
|
|
21
|
-
'X_HIVE_EMPTY',
|
|
22
|
-
'X_EVAL_THRESHOLD',
|
|
23
|
-
'X_EVAL_BASELINE_MISSING',
|
|
24
|
-
'X_EVAL_BASELINE_INVALID',
|
|
25
|
-
'X_EVAL_MISSING',
|
|
26
|
-
'X_EVAL_RECORDING',
|
|
27
|
-
'X_VECTOR_DIM_MISMATCH',
|
|
28
|
-
'X_VECTOR_SCOPE_WIDENED',
|
|
29
|
-
'X_AI_EMBEDDER_INVALID',
|
|
30
|
-
'X_VECTOR_UNSCOPED',
|
|
31
|
-
'X_AI_CONTENT_UNSUPPORTED',
|
|
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',
|
|
38
|
-
'X_AGENT_TOOL_UNDECLARED',
|
|
39
|
-
'X_AGENT_CONTEXT_EXHAUSTED',
|
|
40
|
-
] as const;
|
|
41
|
-
|
|
42
|
-
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
43
|
-
|
|
44
|
-
export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
45
|
-
X_AI_PROVIDER_UNAVAILABLE: 'the model provider is unreachable',
|
|
46
|
-
X_AI_KEY_MISSING: 'the provider API key is not set',
|
|
47
|
-
X_AI_REQUEST_INVALID: 'the provider would reject this request',
|
|
48
|
-
X_AI_BUDGET_EXCEEDED: 'a model call would exceed its budget',
|
|
49
|
-
X_AI_GATEWAY_MISSING: 'an llm() action ran with no gateway installed',
|
|
50
|
-
X_AI_PROMPT_VERSION: 'prompt version or slots are wrong',
|
|
51
|
-
X_AI_MODEL_UNKNOWN: 'a model id nothing registered in the catalogue',
|
|
52
|
-
X_AI_PROMPT_SECRET: 'a Secret was about to be rendered into a prompt',
|
|
53
|
-
X_LLM_OUTPUT_INVALID: 'structured output failed its schema on the answer and the repair turn',
|
|
54
|
-
X_LLM_REFUSED: 'the model declined the request',
|
|
55
|
-
X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
|
|
56
|
-
X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
|
|
57
|
-
X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
|
|
58
|
-
X_AGENT_TOOL_UNEXPOSED:
|
|
59
|
-
'an agent lists a tool that is neither in-process nor an MCP-exposed action',
|
|
60
|
-
X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
|
|
61
|
-
X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
|
|
62
|
-
X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
|
|
63
|
-
X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
|
|
64
|
-
X_EVAL_MISSING: 'a prompt has no eval',
|
|
65
|
-
X_EVAL_RECORDING: 'the gate ran with baseline recording switched on',
|
|
66
|
-
X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
|
|
67
|
-
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
|
|
68
|
-
X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
|
|
69
|
-
X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
|
|
70
|
-
X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
|
|
71
|
-
X_AI_MODEL_UNRESOLVED: 'a model call named no model',
|
|
72
|
-
X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
|
|
73
|
-
X_AGENT_DEADLINE: 'an agent ran past its declared deadline and was stopped',
|
|
74
|
-
X_LLM_TOOL_ARGUMENTS_INVALID: 'a finished turn called a tool with arguments that are not JSON',
|
|
75
|
-
X_AGENT_TRANSCRIPT_INVALID: 'an agent was asked to continue from a transcript it cannot read',
|
|
76
|
-
X_AI_PROVIDER_UNEXPECTED:
|
|
77
|
-
'a provider ended a turn with an unknown reason and an incomplete answer',
|
|
78
|
-
X_AGENT_TOOL_UNDECLARED: 'an agent run offered a tool its declaration does not list',
|
|
79
|
-
X_AGENT_CONTEXT_EXHAUSTED: "an agent's next request would not fit its model's context window",
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
|
83
|
-
// in one call: every code above is owned here, so a second package claiming one is a real conflict
|
|
84
|
-
// that has to surface as X_ERROR_CODE_DUPLICATE rather than resolve to whoever imported first.
|
|
85
|
-
// `X_FORBIDDEN` (policy's) and `X_CURRENCY_MISMATCH` (money's) are thrown here but never titled.
|
|
86
|
-
registerErrorCodes(
|
|
87
|
-
Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
88
|
-
);
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
// The codes, their titles and the single registration call are `./error-codes`, imported here for
|
|
6
|
+
// that side effect.
|
|
7
|
+
import './error-codes';
|
|
89
8
|
|
|
90
9
|
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
91
10
|
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The agent arm of `defineEval`: one case is one whole `agent.run()` — its tools, its turns — under
|
|
2
|
+
// the gateway the eval was handed, with every tool answered by the eval's double. Split from
|
|
3
|
+
// ./evals so the prompt arm and the gate stay one file each.
|
|
4
|
+
|
|
5
|
+
import type { AnyAction, InvokeOptions } from '@ultimat3/action';
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { assert, serviceActor, stringField } from '@ultimat3/core';
|
|
8
|
+
import type { AgentRun } from './agent-continue';
|
|
9
|
+
import { agentFactsOf } from './agent-facts';
|
|
10
|
+
import type { Gateway } from './gateway';
|
|
11
|
+
import type { Prompt } from './prompt';
|
|
12
|
+
import { describePrompts } from './prompt';
|
|
13
|
+
import type { ToolDouble } from './runtime';
|
|
14
|
+
import { withEvalScope } from './runtime';
|
|
15
|
+
import type { EvalRun, Scorer } from './scorers';
|
|
16
|
+
|
|
17
|
+
/** What the agent arm needs of an `agent()` action: its name, and `.run()`. */
|
|
18
|
+
export interface EvaluatedAgent<I = unknown> extends AnyAction {
|
|
19
|
+
run(input: I, opts?: InvokeOptions): Promise<AgentRun<unknown>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One agent case: the agent's own input, plus what a good answer looks like. */
|
|
23
|
+
export interface AgentEvalCase<I = unknown> {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly input: I;
|
|
26
|
+
/** Reference answer, or the substring/JSON the scorers check for. */
|
|
27
|
+
readonly expected?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgentEvalInput<I = unknown> {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/** An `agent()` action. Its prompt is the one this eval covers, and its hash keys the baseline. */
|
|
33
|
+
readonly agent: EvaluatedAgent<I>;
|
|
34
|
+
readonly cases: readonly AgentEvalCase<I>[];
|
|
35
|
+
/**
|
|
36
|
+
* One double per tool the model may call. An eval NEVER runs a real tool: a call to a tool with
|
|
37
|
+
* no double here ends the run as `X_EVAL_TOOL_UNDOUBLED`.
|
|
38
|
+
*/
|
|
39
|
+
readonly tools: Readonly<Record<string, ToolDouble>>;
|
|
40
|
+
readonly scorers: readonly Scorer[];
|
|
41
|
+
/** As on the prompt arm: `import.meta.resolve('./name.baseline.json')`. */
|
|
42
|
+
readonly baseline: string;
|
|
43
|
+
readonly tolerance: number;
|
|
44
|
+
/** Who each case runs as — for a policy that needs one. Default: the `eval` service actor. */
|
|
45
|
+
readonly actor?: Actor;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What one case produced, before it is scored. `run` is the agent arm's. */
|
|
49
|
+
export type CaseOutcome =
|
|
50
|
+
| { readonly ok: true; readonly output: string; readonly run?: EvalRun }
|
|
51
|
+
| { readonly ok: false; readonly code: string };
|
|
52
|
+
|
|
53
|
+
/** The default identity of an agent eval: a service, never a person a policy would mistake it for. */
|
|
54
|
+
export const EVAL_ACTOR: Actor = serviceActor({ id: 'eval' });
|
|
55
|
+
|
|
56
|
+
/** The code that means the EVAL is wrong rather than the agent: never scored, always thrown. */
|
|
57
|
+
const MISDECLARED = 'X_EVAL_TOOL_UNDOUBLED';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The prompt an agent runs on, read off the agent registry. An action `agent()` did not build — or
|
|
61
|
+
* a `named()` twin, which the registry never sees — has none, and an eval of it would cover nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function agentPrompt(agent: AnyAction, evalName: string): Prompt {
|
|
64
|
+
const ref = agentFactsOf(agent)?.prompt;
|
|
65
|
+
const prompt = ref === undefined ? undefined : describePrompts().find((p) => p.ref === ref);
|
|
66
|
+
assert(
|
|
67
|
+
prompt !== undefined,
|
|
68
|
+
`defineEval "${evalName}" names an agent whose prompt is not registered: an action agent() did not build, a named() twin, or a prompt reset since`,
|
|
69
|
+
`registerAction('<name>', agent({ … })) and pass that action to defineEval({ agent })`,
|
|
70
|
+
);
|
|
71
|
+
return prompt;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run one case. A failed run — a loop, `X_AGENT_MAX_TURNS`, an invalid answer — is an OUTCOME the
|
|
76
|
+
* eval scores 0 and reports by code, so a regression never crashes the suite. An undoubled tool
|
|
77
|
+
* call is the one failure rethrown: the eval is misdeclared, and scoring it would let a recording
|
|
78
|
+
* accept a case that tried to run a real tool.
|
|
79
|
+
*/
|
|
80
|
+
export async function runAgentCase(
|
|
81
|
+
input: AgentEvalInput,
|
|
82
|
+
testCase: AgentEvalCase,
|
|
83
|
+
gateway: Gateway,
|
|
84
|
+
): Promise<CaseOutcome> {
|
|
85
|
+
const scope = { eval: input.name, gateway, tools: input.tools, calls: [] };
|
|
86
|
+
try {
|
|
87
|
+
const run = await withEvalScope(scope, () =>
|
|
88
|
+
input.agent.run(testCase.input, { actor: input.actor ?? EVAL_ACTOR }),
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
output: JSON.stringify(run.output) ?? 'null',
|
|
93
|
+
run: { turns: run.turns, toolCalls: [...scope.calls], usage: run.usage },
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const code = stringField(error, 'code');
|
|
97
|
+
if (code === MISDECLARED) throw error;
|
|
98
|
+
return { ok: false, code: code ?? 'uncoded' };
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/eval-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The six X_EVAL_* codes, apart from ./errors only because one file has one job and the catalogue
|
|
2
2
|
// outgrew its ceiling. The codes themselves, their titles and the single `registerErrorCodes` call
|
|
3
|
-
// stay in ./
|
|
3
|
+
// stay in ./error-codes — one owner, one registration, one place a duplicate can surface.
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from '@ultimat3/core';
|
|
6
6
|
|
|
@@ -73,11 +73,37 @@ export class EvalBaselineInvalidError extends UltimateError {
|
|
|
73
73
|
* money and answers users, so the gate fails on it exactly like an untyped module.
|
|
74
74
|
*/
|
|
75
75
|
export class EvalMissingError extends UltimateError {
|
|
76
|
-
|
|
76
|
+
/** `agent`: the agent the prompt drives, so the fix names the arm that runs its tools and turns. */
|
|
77
|
+
constructor(input: { prompt: string; id: string; agent?: string }) {
|
|
78
|
+
const arm =
|
|
79
|
+
input.agent === undefined
|
|
80
|
+
? 'prompt, cases, scorers'
|
|
81
|
+
: `agent: ${input.agent}, cases, tools, scorers`;
|
|
77
82
|
super({
|
|
78
83
|
code: 'X_EVAL_MISSING',
|
|
79
|
-
cause:
|
|
80
|
-
|
|
84
|
+
cause:
|
|
85
|
+
input.agent === undefined
|
|
86
|
+
? `prompt "${input.prompt}" has no eval`
|
|
87
|
+
: `prompt "${input.prompt}" has no eval, and agent ${input.agent} runs on it`,
|
|
88
|
+
fix: `defineEval({ name: '${input.id}', ${arm}, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* An agent eval's model called a tool the eval declares no double for. An eval NEVER runs a real
|
|
95
|
+
* tool — a case that reached one in CI would write — so the run ends here rather than reading the
|
|
96
|
+
* call as a tool failure the model could route around, or scoring it 0 and letting a recording
|
|
97
|
+
* accept it. Thrown out of `run` / `assert`: the eval is misdeclared, not the agent regressed.
|
|
98
|
+
*/
|
|
99
|
+
export class EvalToolUndoubledError extends UltimateError {
|
|
100
|
+
constructor(input: { eval: string; tool: string; doubled: readonly string[] }) {
|
|
101
|
+
const doubled = input.doubled.length === 0 ? 'none' : input.doubled.join(', ');
|
|
102
|
+
super({
|
|
103
|
+
code: 'X_EVAL_TOOL_UNDOUBLED',
|
|
104
|
+
cause: `agent eval "${input.eval}" ran a case whose model called ${input.tool}, which has no double (doubled: ${doubled}); an eval never runs a real tool`,
|
|
105
|
+
fix: `tools: { ${input.tool}: (input) => ({ /* what the tool answers */ }) } # on defineEval({ name: '${input.eval}' })`,
|
|
106
|
+
meta: { eval: input.eval, tool: input.tool },
|
|
81
107
|
});
|
|
82
108
|
}
|
|
83
109
|
}
|