@ultimat3/ai 27.11.0 → 27.13.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 CHANGED
@@ -64,6 +64,7 @@ one adds, the other adds.
64
64
  | `agent-guards.ts` / `agent-errors.ts` | the repeated-turn detector and the run deadline; their two classes |
65
65
  | `prompt-cache.ts` | PROVIDER prompt caching: `SystemParts`, `PromptCacheHint`, `PROMPT_CACHE_RULES` (sources in its header) |
66
66
  | `prompt-cache-blocks.ts` | the Anthropic wire's marks (chat-completions': `openai-messages.ts`) |
67
+ | `agent-per-run.ts` / `agent-run-limits.ts` | one run's `offerTools()`/`model()`/`limits()`, narrow only; `costPerRun` |
67
68
  | `agent-continue.ts` / `agent-turn.ts` | `.run()` / `.continue()`, the transcript handle and its screen; one turn's report and refusals |
68
69
  | `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
69
70
  | `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish (its consumer has to be `cli`, tier 5) |
@@ -236,6 +237,9 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
236
237
  rule a hive member's `reason` follows too. Never a second copy. One widening, for an
237
238
  `inProcess` tool only (`discloseOwnFailure`): a branded error under a code with NO declared
238
239
  status is shown as `{ error: { code, cause, fix } }`; `discloseErrors: false` opts out.
240
+ - **A run only NARROWS its declaration**: `offerTools()` ⊆ declared names (`X_AGENT_TOOL_UNDECLARED`),
241
+ `model()` via `modelSpec`, `limits()` screened and ≤ declared (`X_AI_REQUEST_INVALID`); all
242
+ before `vars()`. The handle binds the run's model and OFFERED subset; `costPerRun` is checked per turn.
239
243
  - **`.run()`/`.continue()` are the SAME action**, marked by an ambient session only the declaring
240
244
  agent may claim, once (a nested agent starts fresh). The handle is SIGNED (`sealTranscript`: the
241
245
  cursor secret, own label) and bound to agent, model, tool names and `orgId`; screened before
@@ -276,17 +280,13 @@ Why each is shaped this way: [`docs/history/ai.md`](../../docs/history/ai.md#llm
276
280
  Full text of each rule: [`docs/history/ai.md`](../../docs/history/ai.md#evals-retrieval-fix-lines).
277
281
 
278
282
  - **No fix line may name `x ai`** (planned); an eval is selected with `x test eval --filter <name>`.
279
- - Every eval result carries the prompt hash; an eval gates on the DROP from its recorded baseline,
280
- mean AND per case (`X_EVAL_BASELINE_MISSING` / `_INVALID`); every prompt is named by an eval
281
- (`X_EVAL_MISSING`); `ULTIMATE_EVAL_RECORD=1` records, and `x verify` with it is `X_EVAL_RECORDING`.
282
- - Retrieval is hybrid by default, no vector-only path. `chunk()` hard-wraps (no chunk over `size`);
283
- `metadata.source` is written AFTER the caller's spread; `VectorStore.prune` is required.
283
+ - An eval gates on the DROP from its baseline, mean AND per case; every prompt is named by an eval
284
+ (`X_EVAL_MISSING`); `ULTIMATE_EVAL_RECORD=1` records (`x verify` with it: `X_EVAL_RECORDING`).
285
+ - Retrieval is hybrid, no vector-only path; `chunk()` hard-wraps; `VectorStore.prune` is required.
284
286
  - **An unbound store read inside a request whose actor has an `orgId` is `X_VECTOR_UNSCOPED`**
285
287
  (both stores, every read); `scope: UNSCOPED` is the named opt-in, an omitted scope is `UNBOUND`.
286
- - `postgresVectorStore()` is the only production vector path; `pg-vector.live.test.ts` refuses to
287
- skip without the extension. Distance ordering is an ascending raw subquery (the shape hnsw
288
- answers), pinned by a plan assertion. Tenant and policy filters are in SQL on every statement and
289
- BOTH fusion halves; `(tenant, id)` is the key; `scoped()` only TIGHTENS (`X_VECTOR_SCOPE_WIDENED`).
288
+ - `postgresVectorStore()` is the only production vector path (the live suite never skips); tenant
289
+ and policy filters in SQL on every statement and BOTH fusion halves; `scoped()` only TIGHTENS.
290
290
 
291
291
  ## Commands
292
292
 
package/README.md CHANGED
@@ -442,6 +442,8 @@ sub-agent in its own `tools` and the sub-agent runs under the same actor, throug
442
442
  | both stops are registered `terminal` | an `agentJob()` dead-letters on attempt one instead of buying the same run per attempt |
443
443
  | every turn asks the provider to cache its prefix (`promptCache`, default `'auto'`) | the transcript IS the request, so an uncached run pays for its stable prefix once per turn — [below](#provider-prompt-caching) |
444
444
  | `budget.tokensPerRun` caps the **whole run** | a single call is bounded by `maxTokens`; a loop is bounded by nothing until this is set |
445
+ | `budget.costPerRun` caps the run's **money**: before each turn, what the run cost so far (continuations and nested calls included) plus that turn's worst-case estimate | `costPerCall` bounds one call and nothing bounded their sum; refused pre-flight as `X_AI_BUDGET_EXCEEDED` (scope `costPerRun`), never truncated |
446
+ | one run may **narrow** its declaration, never widen it: `offerTools()` a subset of `tools` (another name is `X_AGENT_TOOL_UNDECLARED`), `model` as a function (resolved through the registry, `X_AI_MODEL_UNKNOWN`), `limits()` lower ceilings (a higher one is `X_AI_REQUEST_INVALID`) | a stored permission rule or model tier needed one declared agent per combination; the declaration stays the blast radius, and the handle binds the run's model and offered subset — [Agents → Per-run picks](https://github.com/developerz-ai/ultimate/wiki/Agents#per-run-picks-offertools-model-limits) |
445
447
  | a tool result is truncated, and says so | the transcript IS the request, so an untruncated result is re-billed once per remaining turn |
446
448
  | **no semantic cache** | similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second |
447
449
 
@@ -701,7 +703,7 @@ import { describeAgents } from '@ultimat3/ai';
701
703
  describeAgents();
702
704
  // [{ name: 'supportAgent', prompt: 'support@1.0.0', promptHash: '…', model: 'house-large', modelFrom: 'gateway',
703
705
  // maxTurns: 6, maxToolResultChars: 4000, tools: ['issueRefund', 'lookupOrder'],
704
- // budget: { tokensIn: null, tokensPerRun: 200000, costPerCall: { minor: 50, currency: 'USD' } },
706
+ // budget: { tokensIn: null, tokensPerRun: 200000, costPerCall: { minor: 50, currency: 'USD' }, costPerRun: null },
705
707
  // mcp: true }]
706
708
  ```
707
709
 
@@ -966,6 +968,7 @@ fail-closed `terminal` default.
966
968
  | `X_LLM_STREAM_INVALID` | a streamed answer failed its schema, and a stream cannot take a repair turn |
967
969
  | `X_AGENT_MAX_TURNS` | an `agent()` used every turn without answering |
968
970
  | `X_AGENT_TOOL_UNEXPOSED` | an `agent()` lists a tool that is neither in-process nor an MCP-exposed action |
971
+ | `X_AGENT_TOOL_UNDECLARED` | an `agent()` run's `offerTools()` named a tool its declaration does not list |
969
972
  | `X_AGENT_TOOL_LOOP` | an `agent()` made the same tool calls and read the same results `maxRepeatedToolCalls` turns in a row |
970
973
  | `X_AGENT_DEADLINE` | an `agent()` outlived its declared `deadlineMs` |
971
974
  | `X_EVAL_THRESHOLD` | an eval scored below its bar |
@@ -986,6 +989,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
986
989
  | `AgentDeadlineError` | `X_AGENT_DEADLINE` | `src/agent-errors.ts` |
987
990
  | `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
988
991
  | `AgentToolLoopError` | `X_AGENT_TOOL_LOOP` | `src/agent-errors.ts` |
992
+ | `AgentToolUndeclaredError` | `X_AGENT_TOOL_UNDECLARED` | `src/agent-errors.ts` |
989
993
  | `AgentTranscriptInvalidError` | `X_AGENT_TRANSCRIPT_INVALID` | `src/agent-errors.ts` |
990
994
  | `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
991
995
  | `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "27.11.0",
3
+ "version": "27.13.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,15 +32,15 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "27.11.0",
36
- "@ultimat3/cache": "27.11.0",
37
- "@ultimat3/core": "27.11.0",
38
- "@ultimat3/db": "27.11.0",
39
- "@ultimat3/http": "27.11.0",
40
- "@ultimat3/jobs": "27.11.0",
41
- "@ultimat3/money": "27.11.0",
42
- "@ultimat3/policy": "27.11.0",
43
- "@ultimat3/schema": "27.11.0",
44
- "@ultimat3/time": "27.11.0"
35
+ "@ultimat3/action": "27.13.0",
36
+ "@ultimat3/cache": "27.13.0",
37
+ "@ultimat3/core": "27.13.0",
38
+ "@ultimat3/db": "27.13.0",
39
+ "@ultimat3/http": "27.13.0",
40
+ "@ultimat3/jobs": "27.13.0",
41
+ "@ultimat3/money": "27.13.0",
42
+ "@ultimat3/policy": "27.13.0",
43
+ "@ultimat3/schema": "27.13.0",
44
+ "@ultimat3/time": "27.13.0"
45
45
  }
46
46
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { finiteCount, isMcpExposed } from '@ultimat3/core';
6
6
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
- import type { AgentDef } from './agent-def';
7
+ import type { AgentBudget, AgentDef } from './agent-def';
8
8
  import type { AgentFact } from './agent-facts';
9
9
  import { DEFAULT_MAX_REPEATED_TOOL_CALLS } from './agent-guards';
10
10
  import type { BudgetLimits } from './budget';
@@ -107,7 +107,10 @@ export function factsOf<
107
107
  // The run's own precedence, so the row names the model a call would use NOW: read when the
108
108
  // facts are, at describe time, like every name in the row. `modelFrom` says which place
109
109
  // answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
110
- ...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
110
+ // A model picked per run (`model` as a function) has no answer until a run asks it.
111
+ ...(typeof def.model === 'function'
112
+ ? { model: null, modelFrom: 'run' as const }
113
+ : modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel))),
111
114
  maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
112
115
  maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
113
116
  tools: [...def.tools.map(toolLabel)].sort(),
@@ -115,17 +118,18 @@ export function factsOf<
115
118
  tokensIn: budget?.tokensIn ?? null,
116
119
  tokensPerRun: budget?.tokensPerRun ?? null,
117
120
  costPerCall: budget?.costPerCall ?? null,
121
+ costPerRun: budget?.costPerRun ?? null,
118
122
  },
119
123
  mcp: isMcpExposed(def.mcp),
120
124
  };
121
125
  }
122
126
 
123
- export function limitsOf<
124
- TInput extends StandardSchemaV1,
125
- TOutput extends StandardSchemaV1,
126
- V extends PromptVars,
127
- >(def: AgentDef<TInput, TOutput, V>, used?: TokenUsage): BudgetLimits {
128
- const budget = def.budget;
127
+ export function limitsOf(budget: AgentBudget | undefined, used?: TokenUsage): BudgetLimits {
128
+ // `costPerRun` is not a ledger scope — the run checks it before each turn (`assertRunCost`) —
129
+ // but it is screened here with the rest, so a bad one is refused before anything is loaded.
130
+ if (budget?.costPerRun !== undefined) {
131
+ screenedMoney(SUBJECT, 'budget.costPerRun', budget.costPerRun);
132
+ }
129
133
  return {
130
134
  // Screened under the names the DECLARATION uses, not the ledger's: `tokensPerRun` arrives at
131
135
  // `BudgetLimits` as `request`, and a fix line naming a key the app never wrote is not a fix.
package/src/agent-def.ts CHANGED
@@ -20,8 +20,38 @@ export interface AgentBudget extends LlmBudget {
20
20
  * a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
21
21
  */
22
22
  readonly tokensPerRun?: number;
23
+ /**
24
+ * Money ceiling for the WHOLE run, every turn and every continuation counted, integer minor
25
+ * units. Checked before each turn against what the run has cost so far plus that turn's
26
+ * worst-case estimate — the run-level twin of `costPerCall`. A refusal is `X_AI_BUDGET_EXCEEDED`
27
+ * (scope `costPerRun`) before the provider is called.
28
+ */
29
+ readonly costPerRun?: Money;
23
30
  }
24
31
 
32
+ /**
33
+ * The ceilings ONE run may lower (`limits()`), under the keys the declaration uses. Each may only
34
+ * be at or under the declared value (after its default); a higher one is `X_AI_REQUEST_INVALID`.
35
+ * One the declaration leaves unset (`deadlineMs`, a `budget` key) may be set by a run: any value
36
+ * is tighter than none.
37
+ */
38
+ export interface AgentRunLimits {
39
+ readonly maxTurns?: number;
40
+ readonly maxTokens?: number;
41
+ readonly maxToolResultChars?: number;
42
+ readonly maxRepeatedToolCalls?: number;
43
+ readonly deadlineMs?: number;
44
+ readonly budget?: AgentBudget;
45
+ }
46
+
47
+ /**
48
+ * The model a run is on: an id, or a function of the run's input and context answering one —
49
+ * `undefined` from it falls through to the prompt's `model`, then the gateway's `defaultModel`.
50
+ */
51
+ export type AgentModel<TInput extends StandardSchemaV1> =
52
+ | ModelId
53
+ | ((args: AgentVarsArgs<TInput>) => ModelId | undefined | Promise<ModelId | undefined>);
54
+
25
55
  export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
26
56
  readonly input: InferOutput<TInput>;
27
57
  readonly ctx: Ctx;
@@ -32,7 +62,13 @@ export interface AgentDef<
32
62
  TOutput extends StandardSchemaV1,
33
63
  V extends PromptVars,
34
64
  > {
35
- readonly model?: ModelId;
65
+ /**
66
+ * An id, or a function picking one per run (a stored override, a tier for this tenant). Either
67
+ * way it is resolved through the model registry — an unregistered id is `X_AI_MODEL_UNKNOWN`
68
+ * before anything is loaded — and bound into the transcript handle, so `.continue()` runs on the
69
+ * model the run started on or is refused.
70
+ */
71
+ readonly model?: AgentModel<TInput>;
36
72
  readonly input: TInput;
37
73
  readonly output: TOutput;
38
74
  readonly prompt: Prompt<V>;
@@ -59,6 +95,14 @@ export interface AgentDef<
59
95
  * of both.
60
96
  */
61
97
  readonly tools: readonly AgentTool[];
98
+ /**
99
+ * The tool NAMES this run is offered — a subset of `tools`, picked per run (a stored permission
100
+ * rule, the actor's plan). Unset offers every declared tool. A name the declaration does not
101
+ * list is `X_AGENT_TOOL_UNDECLARED` before anything is loaded: a run narrows the catalogue and
102
+ * never adds to it. The model sees only the subset, a call to any other tool answers
103
+ * `unknown tool`, and the transcript handle is bound to the subset offered.
104
+ */
105
+ offerTools?(args: AgentVarsArgs<TInput>): readonly string[] | Promise<readonly string[]>;
62
106
  /**
63
107
  * `false` withholds the refusals of EVERY in-process tool this agent runs, as a projected tool's
64
108
  * are — for an agent whose transcript leaves the tenant it ran for. Default `true`; one tool
@@ -79,6 +123,12 @@ export interface AgentDef<
79
123
  */
80
124
  readonly deadlineMs?: number;
81
125
  readonly budget?: AgentBudget;
126
+ /**
127
+ * Ceilings for ONE run, read from its input and context (a stored override, a cheaper tier):
128
+ * each may only LOWER the declared one — see `AgentRunLimits`. Called once per `.run()`,
129
+ * `.continue()` or plain call, before the deadline is armed and before `vars()`.
130
+ */
131
+ limits?(args: AgentVarsArgs<TInput>): AgentRunLimits | Promise<AgentRunLimits>;
82
132
  readonly policy: ActionPolicy;
83
133
  readonly mcp?: ActionMcp;
84
134
  /** Enforced completion ceiling PER TURN. The model never sees it. */
@@ -67,3 +67,20 @@ export class AgentTranscriptInvalidError extends UltimateError {
67
67
  });
68
68
  }
69
69
  }
70
+
71
+ /**
72
+ * One run's `offerTools()` named a tool the declaration does not list. Refused rather than
73
+ * dropped, for the reason `X_AGENT_TOOL_UNEXPOSED` is: a name that reads as offered and silently is
74
+ * not is the worst of both — and a per-run choice can only NARROW the declared catalogue, never
75
+ * add to it, so the declaration stays the agent's whole blast radius.
76
+ */
77
+ export class AgentToolUndeclaredError extends UltimateError {
78
+ constructor(input: { agent: string; tools: readonly string[]; declared: readonly string[] }) {
79
+ super({
80
+ code: 'X_AGENT_TOOL_UNDECLARED',
81
+ cause: `agent "${input.agent}" was offered tools its declaration does not list: ${input.tools.join(', ')} (declared: ${input.declared.join(', ') || 'none'})`,
82
+ fix: "offerTools: () => ['<a name from the cause's declared list>'] # on the agent — or add the tool to its tools: first if this run really needs it",
83
+ meta: { agent: input.agent, tools: input.tools },
84
+ });
85
+ }
86
+ }
@@ -16,6 +16,7 @@ export interface AgentBudgetFact {
16
16
  readonly tokensIn: number | null;
17
17
  readonly tokensPerRun: number | null;
18
18
  readonly costPerCall: Money | null;
19
+ readonly costPerRun: Money | null;
19
20
  }
20
21
 
21
22
  export interface AgentFact {
@@ -33,11 +34,17 @@ export interface AgentFact {
33
34
  * `null` when none names one yet — a call in that state is refused (`X_AI_MODEL_UNRESOLVED`).
34
35
  */
35
36
  readonly model: string | null;
36
- /** Which of those answered; `gateway` and `null` are as of the gateway installed at read time. */
37
- readonly modelFrom: ModelSource | null;
37
+ /**
38
+ * Which of those answered; `gateway` and `null` are as of the gateway installed at read time.
39
+ * `run` (with `model: null`): the declaration's `model` is a function, so each run picks its own.
40
+ */
41
+ readonly modelFrom: ModelSource | 'run' | null;
38
42
  readonly maxTurns: number;
39
43
  readonly maxToolResultChars: number;
40
- /** Tool names, sorted — the catalogue this agent may call, which is its blast radius. */
44
+ /**
45
+ * Tool names, sorted — the catalogue this agent may call, which is its blast radius. A run's
46
+ * `offerTools()` only ever narrows it.
47
+ */
41
48
  readonly tools: readonly string[];
42
49
  readonly budget: AgentBudgetFact;
43
50
  /** Whether the agent itself is offered as a tool, so a supervisor could call it. */
@@ -0,0 +1,117 @@
1
+ // Single responsibility: what ONE run of an `agent()` picks from its declaration — the tools it is
2
+ // offered (`offerTools()`), the model it runs on (`model` as a function) and the ceilings it runs
3
+ // under (`limits()`) — each read from the run's own input and context, and each only ever a
4
+ // NARROWING of what the declaration states. Split from ./agent at its line ceiling.
5
+
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
8
+ import { limitsOf } from './agent-declared';
9
+ import type { AgentDef } from './agent-def';
10
+ import { AgentToolUndeclaredError } from './agent-errors';
11
+ import type { RunCeilings } from './agent-run-limits';
12
+ import { narrowCeilings } from './agent-run-limits';
13
+ import { AiRequestInvalidError } from './errors';
14
+ import { modelForCall } from './model-resolve';
15
+ import type { ModelId } from './models';
16
+ import { modelSpec } from './models';
17
+ import type { PromptVars } from './prompt';
18
+ import type { AgentTool, LlmTool, ProjectableAction } from './tools';
19
+ import { asProjectableAction, toLlmTools } from './tools';
20
+
21
+ /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
22
+ export interface Adapted {
23
+ readonly tools: readonly ProjectableAction[];
24
+ readonly offered: readonly LlmTool[];
25
+ }
26
+
27
+ export function project(tools: readonly AgentTool[]): Adapted {
28
+ const projected = tools.map(asProjectableAction);
29
+ return { tools: projected, offered: toLlmTools(projected) };
30
+ }
31
+
32
+ /** One run's picks, resolved before anything is loaded, reserved or sent. */
33
+ export interface PerRun extends RunCeilings {
34
+ readonly model: ModelId;
35
+ readonly adapted: Adapted;
36
+ }
37
+
38
+ type RunArgs<TInput extends StandardSchemaV1> = {
39
+ readonly input: InferOutput<TInput>;
40
+ readonly ctx: Ctx;
41
+ };
42
+
43
+ export async function resolvePerRun<
44
+ TInput extends StandardSchemaV1,
45
+ TOutput extends StandardSchemaV1,
46
+ V extends PromptVars,
47
+ >(
48
+ def: AgentDef<TInput, TOutput, V>,
49
+ declared: RunCeilings & { readonly adapted: Adapted },
50
+ args: RunArgs<TInput>,
51
+ ): Promise<PerRun> {
52
+ const name = def.prompt.ref;
53
+ // The declared budget first, so a bad declaration is refused whether or not a gateway exists —
54
+ // then the run's own ceilings, screened the same way after narrowing.
55
+ limitsOf(declared.budget);
56
+ const ceilings = narrowCeilings(
57
+ name,
58
+ declared,
59
+ def.limits === undefined ? undefined : await def.limits(args),
60
+ );
61
+ limitsOf(ceilings.budget);
62
+ const adapted =
63
+ def.offerTools === undefined
64
+ ? declared.adapted
65
+ : offered(name, declared.adapted, await def.offerTools(args));
66
+ const model = await modelOf(def, args);
67
+ return { ...ceilings, model, adapted };
68
+ }
69
+
70
+ /**
71
+ * The declaration, its prompt, then the gateway's `defaultModel` — the app's three places, with
72
+ * the first one asked per run when it is a function. A per-run id is refused here when it is not
73
+ * registered (`X_AI_MODEL_UNKNOWN`), before `vars()` loads anything: it is data the app read from
74
+ * somewhere, and the gateway's own check would come only after the load.
75
+ */
76
+ async function modelOf<
77
+ TInput extends StandardSchemaV1,
78
+ TOutput extends StandardSchemaV1,
79
+ V extends PromptVars,
80
+ >(def: AgentDef<TInput, TOutput, V>, args: RunArgs<TInput>): Promise<ModelId> {
81
+ const name = def.prompt.ref;
82
+ if (typeof def.model !== 'function') {
83
+ return modelForCall('agent', name, def.model, def.prompt.model);
84
+ }
85
+ const picked: unknown = await def.model(args);
86
+ if (picked !== undefined && (typeof picked !== 'string' || picked === '')) {
87
+ throw new AiRequestInvalidError({
88
+ detail: `agent "${name}" model() answered ${picked === '' ? 'an empty string' : typeof picked}, not a model id`,
89
+ fix: 'model: ({ input, ctx }) => modelId # an id your app registerModel-ed, or undefined for the prompt model, then the gateway defaultModel',
90
+ });
91
+ }
92
+ const model = modelForCall('agent', name, picked, def.prompt.model);
93
+ modelSpec(model);
94
+ return model;
95
+ }
96
+
97
+ /**
98
+ * The declared tools narrowed to `names`, in the catalogue's own order (so the request is the same
99
+ * bytes whatever order a rule lists them in, and a provider's prompt cache still reads it back). A
100
+ * repeated name is one tool; a name the declaration does not list is refused, never dropped.
101
+ */
102
+ function offered(agent: string, adapted: Adapted, names: unknown): Adapted {
103
+ if (!Array.isArray(names) || names.some((entry) => typeof entry !== 'string')) {
104
+ throw new AiRequestInvalidError({
105
+ detail: `agent "${agent}" offerTools() answered something other than an array of tool names`,
106
+ fix: "offerTools: ({ input, ctx }) => ['toolName'] # names from the agent's tools",
107
+ });
108
+ }
109
+ const wanted = new Set<string>(names);
110
+ const declared = adapted.tools.map((tool) => tool.name);
111
+ const unknown = [...wanted].filter((entry) => !declared.includes(entry));
112
+ if (unknown.length > 0) {
113
+ throw new AgentToolUndeclaredError({ agent, tools: unknown, declared });
114
+ }
115
+ const tools = adapted.tools.filter((tool) => wanted.has(tool.name));
116
+ return { tools, offered: adapted.offered.filter((tool) => wanted.has(tool.name)) };
117
+ }
@@ -0,0 +1,148 @@
1
+ // Single responsibility: the ceilings ONE agent run runs under — the declared bounds narrowed by
2
+ // that run's own `limits()`, never widened — and the run-level money ceiling (`costPerRun`),
3
+ // checked before each turn. Split from ./agent-declared, which owns the declaration's half.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import type { Money } from '@ultimat3/money';
7
+ import { assertSameCurrency, commonScale, exponentOf, minorAt } from '@ultimat3/money';
8
+ import type { AgentBounds } from './agent-declared';
9
+ import { SUBJECT } from './agent-declared';
10
+ import type { AgentBudget, AgentRunLimits } from './agent-def';
11
+ import { screenedMoney } from './budget';
12
+ import { compareMoney } from './cost';
13
+ import { AiBudgetExceededError, AiRequestInvalidError } from './errors';
14
+
15
+ /** What one run is bounded by: the loop bounds, the deadline and the budget, after `limits()`. */
16
+ export interface RunCeilings {
17
+ readonly bounds: AgentBounds;
18
+ readonly deadlineMs: number | undefined;
19
+ readonly budget: AgentBudget | undefined;
20
+ }
21
+
22
+ /** The key a refusal names: `limits().budget.tokensPerRun`, as the app's function wrote it. */
23
+ const KEY = 'limits()';
24
+
25
+ /**
26
+ * The run's ceilings: `asked` screened like a declared bound (`finiteCount` — a `NaN` here would
27
+ * switch a ceiling off), then held at or under `declared`. A higher value is refused, not clamped:
28
+ * a function that asks for more than the declaration allows is a wrong rule, and clamping it
29
+ * silently would hide that until a run needed the turns it was promised.
30
+ */
31
+ export function narrowCeilings(
32
+ agent: string,
33
+ declared: RunCeilings,
34
+ asked: AgentRunLimits | undefined,
35
+ ): RunCeilings {
36
+ if (asked === undefined) return declared;
37
+ const lower = (
38
+ key: string,
39
+ value: number | undefined,
40
+ ceiling: number | undefined,
41
+ min: 0 | 1 = 0,
42
+ ): number | undefined => {
43
+ if (value === undefined) return ceiling;
44
+ const screened = finiteCount(SUBJECT, `${KEY}.${key}`, value, min);
45
+ if (ceiling !== undefined && screened > ceiling) refuseWider(agent, key, value, ceiling);
46
+ return screened;
47
+ };
48
+ const { bounds } = declared;
49
+ const repeats = lower(
50
+ 'maxRepeatedToolCalls',
51
+ asked.maxRepeatedToolCalls,
52
+ bounds.maxRepeatedToolCalls,
53
+ 1,
54
+ );
55
+ if (repeats !== undefined && repeats < 2) {
56
+ throw new AiRequestInvalidError({
57
+ detail: `agent "${agent}" limits() set maxRepeatedToolCalls to ${repeats}, which would stop the run at its first tool turn`,
58
+ fix: 'maxRepeatedToolCalls: 2 # in what limits() returns — at least 2; it counts consecutive identical turns, the first included',
59
+ });
60
+ }
61
+ const narrowed: AgentBounds = {
62
+ maxTurns: lower('maxTurns', asked.maxTurns, bounds.maxTurns, 1) ?? bounds.maxTurns,
63
+ maxTokens: lower('maxTokens', asked.maxTokens, bounds.maxTokens, 1) ?? bounds.maxTokens,
64
+ maxToolResultChars:
65
+ lower('maxToolResultChars', asked.maxToolResultChars, bounds.maxToolResultChars, 1) ??
66
+ bounds.maxToolResultChars,
67
+ maxRepeatedToolCalls: repeats ?? bounds.maxRepeatedToolCalls,
68
+ };
69
+ const deadlineMs = lower('deadlineMs', asked.deadlineMs, declared.deadlineMs, 1);
70
+ return {
71
+ bounds: narrowed,
72
+ deadlineMs,
73
+ budget: narrowBudget(agent, declared.budget, asked.budget),
74
+ };
75
+ }
76
+
77
+ function narrowBudget(
78
+ agent: string,
79
+ declared: AgentBudget | undefined,
80
+ asked: AgentBudget | undefined,
81
+ ): AgentBudget | undefined {
82
+ if (asked === undefined) return declared;
83
+ const count = (key: 'tokensIn' | 'tokensPerRun'): number | undefined => {
84
+ const value = asked[key];
85
+ const ceiling = declared?.[key];
86
+ if (value === undefined) return ceiling;
87
+ const screened = finiteCount(SUBJECT, `${KEY}.budget.${key}`, value);
88
+ if (ceiling !== undefined && screened > ceiling) {
89
+ refuseWider(agent, `budget.${key}`, value, ceiling);
90
+ }
91
+ return screened;
92
+ };
93
+ const money = (key: 'costPerCall' | 'costPerRun'): Money | undefined => {
94
+ const value = asked[key];
95
+ const ceiling = declared?.[key];
96
+ if (value === undefined) return ceiling;
97
+ screenedMoney(SUBJECT, `${KEY}.budget.${key}`, value);
98
+ if (ceiling !== undefined) {
99
+ assertSameCurrency(ceiling, value);
100
+ if (compareMoney(value, ceiling) > 0) {
101
+ refuseWider(agent, `budget.${key}`, `${value.minor} ${value.currency}`, ceiling.minor);
102
+ }
103
+ }
104
+ return value;
105
+ };
106
+ const tokensIn = count('tokensIn');
107
+ const tokensPerRun = count('tokensPerRun');
108
+ const costPerCall = money('costPerCall');
109
+ const costPerRun = money('costPerRun');
110
+ return {
111
+ ...(tokensIn === undefined ? {} : { tokensIn }),
112
+ ...(tokensPerRun === undefined ? {} : { tokensPerRun }),
113
+ ...(costPerCall === undefined ? {} : { costPerCall }),
114
+ ...(costPerRun === undefined ? {} : { costPerRun }),
115
+ };
116
+ }
117
+
118
+ function refuseWider(agent: string, key: string, value: number | string, ceiling: number): never {
119
+ throw new AiRequestInvalidError({
120
+ detail: `agent "${agent}" limits() asked for ${key} ${value}, over the declared ${ceiling} — a run may only lower a declared ceiling`,
121
+ fix: `${key}: ${ceiling} # at most this in what limits() returns — or raise ${key} on the agent declaration first`,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * `costPerRun`, before one turn: what the run has cost so far (every turn, every continuation,
127
+ * every nested call reported up) plus this turn's worst-case estimate, against the ceiling. The
128
+ * figures are compared at one scale and refused, never truncated, exactly as `costPerCall` is.
129
+ */
130
+ export function assertRunCost(limit: Money, spent: Money, estimate: Money): void {
131
+ assertSameCurrency(limit, spent);
132
+ assertSameCurrency(limit, estimate);
133
+ const scale = Math.max(commonScale(limit, spent), commonScale(limit, estimate));
134
+ const ceiling = minorAt(limit, scale);
135
+ const used = minorAt(spent, scale);
136
+ const want = minorAt(estimate, scale);
137
+ if (used + want <= ceiling) return;
138
+ throw new AiBudgetExceededError({
139
+ scope: 'costPerRun',
140
+ requested: Number(want),
141
+ remaining: Number(ceiling - used),
142
+ limit: Number(ceiling),
143
+ unit:
144
+ scale === exponentOf(limit.currency)
145
+ ? `${limit.currency} minor units`
146
+ : `${limit.currency} at scale ${scale}`,
147
+ });
148
+ }
package/src/agent.ts CHANGED
@@ -35,12 +35,14 @@ import {
35
35
  sessionCalls,
36
36
  withTranscript,
37
37
  } from './agent-continue';
38
- import type { AgentBounds } from './agent-declared';
39
38
  import { factsOf, limitsOf, SUBJECT, screenBounds } from './agent-declared';
40
39
  import type { AgentDef } from './agent-def';
41
40
  import { AgentTranscriptInvalidError } from './agent-errors';
42
41
  import { registerAgentFact } from './agent-facts';
43
42
  import { runDeadline, toolLoopGuard } from './agent-guards';
43
+ import type { Adapted, PerRun } from './agent-per-run';
44
+ import { project, resolvePerRun } from './agent-per-run';
45
+ import { assertRunCost } from './agent-run-limits';
44
46
  import { sameSystem, systemOf } from './agent-system';
45
47
  import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
46
48
  import { assertAnswerable, observeTurn } from './agent-turn';
@@ -49,11 +51,10 @@ import { LlmTruncatedError } from './answer-errors';
49
51
  import { budgetKeysFor, currentBudget, withBudget } from './budget';
50
52
  import { AgentMaxTurnsError, AgentToolUnexposedError, LlmOutputInvalidError } from './errors';
51
53
  import { answerAttributes } from './llm';
52
- import { modelForCall } from './model-resolve';
53
54
  import type { PromptVars } from './prompt';
54
55
  import { promptCacheHint } from './prompt-cache';
55
56
  import type { AiMessage, GenerateRequest } from './provider';
56
- import { isTruncated } from './provider';
57
+ import { estimateCost, isTruncated } from './provider';
57
58
  import { assertNoSecrets } from './redaction';
58
59
  import type { Respond } from './respond';
59
60
  import { RESPOND, respondFor } from './respond';
@@ -61,10 +62,17 @@ import { runMeter } from './run-meter';
61
62
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
62
63
  import type { Sampling } from './sampling';
63
64
  import { ignoredSamplingAttribute, screenSampling } from './sampling';
64
- import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
65
- import { asProjectableAction, isOfferedTool, runLlmToolCall, toLlmTools, toolLabel } from './tools';
65
+ import type { LlmToolResult } from './tools';
66
+ import { isOfferedTool, runLlmToolCall, toolLabel } from './tools';
66
67
 
67
- export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
68
+ export type {
69
+ AgentBudget,
70
+ AgentDef,
71
+ AgentModel,
72
+ AgentRunLimits,
73
+ AgentTurn,
74
+ AgentVarsArgs,
75
+ } from './agent-def';
68
76
 
69
77
  export function agent<
70
78
  TInput extends StandardSchemaV1,
@@ -84,6 +92,8 @@ export function agent<
84
92
  });
85
93
  }
86
94
  const bounds = screenBounds(def);
95
+ // What every run is held under; a run's `limits()` may only lower it (`narrowCeilings`).
96
+ const declared = { bounds, deadlineMs: def.deadlineMs, budget: def.budget };
87
97
  // This declaration's identity for `.run()` / `.continue()` sessions (`claimAgentSession`).
88
98
  const owner = {};
89
99
  const sampling = screenSampling(SUBJECT, def);
@@ -100,7 +110,14 @@ export function agent<
100
110
  output: def.output,
101
111
  policy: def.policy,
102
112
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
103
- handle: (args) => run(def, respond, adapt(), { ...bounds, sampling, owner }, args),
113
+ handle: async (args) =>
114
+ run(
115
+ def,
116
+ respond,
117
+ await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
118
+ { sampling, owner },
119
+ args,
120
+ ),
104
121
  });
105
122
  // A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
106
123
  // line runs at module scope.
@@ -123,17 +140,6 @@ function continuable<TInput extends StandardSchemaV1, TOutput extends StandardSc
123
140
  });
124
141
  }
125
142
 
126
- /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
127
- interface Adapted {
128
- readonly tools: readonly ProjectableAction[];
129
- readonly offered: readonly LlmTool[];
130
- }
131
-
132
- function project(tools: readonly AgentTool[]): Adapted {
133
- const projected = tools.map(asProjectableAction);
134
- return { tools: projected, offered: toLlmTools(projected) };
135
- }
136
-
137
143
  async function run<
138
144
  TInput extends StandardSchemaV1,
139
145
  TOutput extends StandardSchemaV1,
@@ -141,24 +147,23 @@ async function run<
141
147
  >(
142
148
  def: AgentDef<TInput, TOutput, V>,
143
149
  respond: Respond,
144
- adapted: Adapted,
145
- bounds: AgentBounds & { readonly sampling: Sampling; readonly owner: object },
150
+ /** This run's picks — model, offered tools, ceilings — resolved from the declaration. */
151
+ picked: PerRun,
152
+ fixed: { readonly sampling: Sampling; readonly owner: object },
146
153
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
147
154
  ): Promise<InferOutput<TOutput>> {
148
155
  const { prompt } = def;
149
156
  const name = prompt.ref;
150
157
  // A `.continue()` in flight: its handle is the app's stored data, so it is screened before
151
158
  // anything is loaded, reserved or sent.
152
- const session = claimAgentSession(bounds.owner);
159
+ const session = claimAgentSession(fixed.owner);
160
+ const { adapted, model, bounds } = picked;
153
161
  const resume = session?.resume;
154
162
  if (resume !== undefined && resume.user.trim() === '') {
155
163
  throw new AgentTranscriptInvalidError({ agent: name, reason: 'the user turn is empty' });
156
164
  }
157
- // Screened first: a bad declaration is refused whether or not a gateway exists.
158
- limitsOf(def);
159
- // The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
160
- const model = modelForCall('agent', name, def.model, prompt.model);
161
- // What a handle is bound to, read off the declaration and the request — never off the handle.
165
+ // What a handle is bound to, read off the declaration and the request — never off the handle:
166
+ // the run's own model and the tools THIS run is offered.
162
167
  const binding: TranscriptBinding = {
163
168
  agent: name,
164
169
  model,
@@ -166,9 +171,10 @@ async function run<
166
171
  org: args.ctx.actor.orgId ?? null,
167
172
  };
168
173
  const prior = resume === undefined ? undefined : screenTranscript(binding, resume.transcript);
169
- const limits = limitsOf(def, prior?.usage);
174
+ const limits = limitsOf(picked.budget, prior?.usage);
175
+ const costPerRun = picked.budget?.costPerRun;
170
176
  // Armed before anything is loaded: `vars()` is part of the run a deadline bounds.
171
- const deadline = runDeadline(name, def.deadlineMs, args.ctx.signal);
177
+ const deadline = runDeadline(name, picked.deadlineMs, args.ctx.signal);
172
178
  const redact = aiRedactor();
173
179
  // A continuation does not render the prompt again: the transcript holds what was sent, and
174
180
  // `vars()` — the load — is not run a second time. Its own turn is the caller's text.
@@ -185,6 +191,7 @@ async function run<
185
191
  const system = systemOf(prompt.system, extra, redact);
186
192
  const promptCache = promptCacheHint(def.promptCache, 'auto', args);
187
193
  const repeated = toolLoopGuard(name, bounds.maxRepeatedToolCalls);
194
+ const sampling = fixed.sampling;
188
195
  const { maxTurns, maxToolResultChars: chars } = bounds;
189
196
 
190
197
  return withSpan('ai.agent', async (span) => {
@@ -200,9 +207,7 @@ async function run<
200
207
  // Read when the run ends, not here: the gateway may be what registers the model's row.
201
208
  const reportSampling = (): void => {
202
209
  span.setAttributes(
203
- ignoredSamplingAttribute(model, bounds.sampling, () =>
204
- installedGateway()?.samplingWires?.(model),
205
- ),
210
+ ignoredSamplingAttribute(model, sampling, () => installedGateway()?.samplingWires?.(model)),
206
211
  );
207
212
  };
208
213
 
@@ -220,7 +225,7 @@ async function run<
220
225
  maxTokens: bounds.maxTokens,
221
226
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
222
227
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
223
- ...bounds.sampling,
228
+ ...sampling,
224
229
  tools: [...adapted.offered, respond.tool],
225
230
  // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
226
231
  // not just the top of the next turn: a provider call already in flight is the expensive one.
@@ -254,8 +259,17 @@ async function run<
254
259
  // budget, runs every remaining tool's side effects, and discards the answer.
255
260
  throwIfAborted(args.ctx);
256
261
  deadline.check();
262
+ const request: GenerateRequest = { ...base, messages };
263
+ if (costPerRun !== undefined) {
264
+ // Worst case: the run so far (continuations and nested calls included) plus this turn,
265
+ // estimated as the gateway's own reservation will be — at the write rate only where a
266
+ // provider serving the model would mark the request.
267
+ const spent = runTotal(model, prior?.usage, meter.total(), meter.below()).costExact;
268
+ const marked = gateway.marksPromptCache?.(model) === true;
269
+ assertRunCost(costPerRun, spent, estimateCost(request, marked));
270
+ }
257
271
  const result = await deadline
258
- .guard(meter.call(gateway.generate({ ...base, messages })))
272
+ .guard(meter.call(gateway.generate(request)))
259
273
  .catch((error: unknown) => {
260
274
  // The caller left mid-call: the aborted socket reads as a provider failure, which is
261
275
  // RETRYABLE. Say what happened instead.
@@ -52,6 +52,9 @@ export const AI_ERROR_RETRY = {
52
52
  X_LLM_OUTPUT_INVALID: 'terminal',
53
53
  X_LLM_TOOL_ARGUMENTS_INVALID: 'terminal',
54
54
  X_AGENT_TRANSCRIPT_INVALID: 'terminal',
55
+ // A run whose `offerTools()` named an undeclared tool names it again on the next attempt: the
56
+ // fix is the declaration or the app's own rule, never a retry.
57
+ X_AGENT_TOOL_UNDECLARED: 'terminal',
55
58
  } as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
56
59
 
57
60
  // `Partial`, so the table may be a subset — but every key is still checked against the declared set,
package/src/errors.ts CHANGED
@@ -35,6 +35,7 @@ export const AI_ERROR_CODES = [
35
35
  'X_LLM_TOOL_ARGUMENTS_INVALID',
36
36
  'X_AGENT_TRANSCRIPT_INVALID',
37
37
  'X_AI_PROVIDER_UNEXPECTED',
38
+ 'X_AGENT_TOOL_UNDECLARED',
38
39
  ] as const;
39
40
 
40
41
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -73,6 +74,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
73
74
  X_AGENT_TRANSCRIPT_INVALID: 'an agent was asked to continue from a transcript it cannot read',
74
75
  X_AI_PROVIDER_UNEXPECTED:
75
76
  'a provider ended a turn with an unknown reason and an incomplete answer',
77
+ X_AGENT_TOOL_UNDECLARED: 'an agent run offered a tool its declaration does not list',
76
78
  };
77
79
 
78
80
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
package/src/gateway.ts CHANGED
@@ -86,6 +86,12 @@ export interface ProviderGatewayInput {
86
86
  export interface Gateway {
87
87
  /** The wire formats of the providers serving `model` — what `llm.sampling.ignored` is read from. */
88
88
  samplingWires?(model: ModelId): readonly SamplingWire[];
89
+ /**
90
+ * Whether a provider serving `model` would send a hinted request with a cache mark — so its
91
+ * worst-case estimate prices the prompt at the write rate. Read by `agent()`'s `costPerRun`
92
+ * check to estimate a turn exactly as the gateway's own reservation does; absent reads as no.
93
+ */
94
+ marksPromptCache?(model: ModelId): boolean;
89
95
  /**
90
96
  * The model a call with none declared runs on — `providerGateway({ defaultModel })`. Read by
91
97
  * `llm()` and `agent()` after the declaration and its prompt, so the app's choice is the one
@@ -184,7 +190,9 @@ class GatewayImpl implements Gateway {
184
190
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
185
191
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
186
192
  // only one of them fitted.
187
- const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
193
+ const reservation = await ledger?.reserve(
194
+ estimateSpend(resolved, this.marksPromptCache(model)),
195
+ );
188
196
 
189
197
  let result: GenerateResult;
190
198
  try {
@@ -246,7 +254,9 @@ class GatewayImpl implements Gateway {
246
254
  const provider = this.providerFor(model);
247
255
 
248
256
  const ledger = currentBudget() ?? this.callLedger({});
249
- const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
257
+ const reservation = await ledger?.reserve(
258
+ estimateSpend(resolved, this.marksPromptCache(model)),
259
+ );
250
260
  let settled = false;
251
261
  try {
252
262
  for await (const chunk of provider.stream(resolved)) {
@@ -284,7 +294,7 @@ class GatewayImpl implements Gateway {
284
294
  }
285
295
 
286
296
  /** Whether any provider serving `model` would send a hinted request with a cache mark. */
287
- private marks(model: ModelId): boolean {
297
+ marksPromptCache(model: ModelId): boolean {
288
298
  return this.config.providers.some(
289
299
  (p) => p.models.includes(model) && p.marksPromptCache?.(model) === true,
290
300
  );
package/src/index.ts CHANGED
@@ -17,10 +17,18 @@ export {
17
17
  TRANSCRIPT_VERSION,
18
18
  transcriptOf,
19
19
  } from './agent-continue';
20
- export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
20
+ export type {
21
+ AgentBudget,
22
+ AgentDef,
23
+ AgentModel,
24
+ AgentRunLimits,
25
+ AgentTurn,
26
+ AgentVarsArgs,
27
+ } from './agent-def';
21
28
  export {
22
29
  AgentDeadlineError,
23
30
  AgentToolLoopError,
31
+ AgentToolUndeclaredError,
24
32
  AgentTranscriptInvalidError,
25
33
  } from './agent-errors';
26
34
  export type { AgentBudgetFact, AgentFact } from './agent-facts';