@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 +9 -9
- package/README.md +5 -1
- package/package.json +11 -11
- package/src/agent-declared.ts +12 -8
- package/src/agent-def.ts +51 -1
- package/src/agent-errors.ts +17 -0
- package/src/agent-facts.ts +10 -3
- package/src/agent-per-run.ts +117 -0
- package/src/agent-run-limits.ts +148 -0
- package/src/agent.ts +47 -33
- package/src/errors-retry.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/gateway.ts +13 -3
- package/src/index.ts +9 -1
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
|
-
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
287
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/cache": "27.
|
|
37
|
-
"@ultimat3/core": "27.
|
|
38
|
-
"@ultimat3/db": "27.
|
|
39
|
-
"@ultimat3/http": "27.
|
|
40
|
-
"@ultimat3/jobs": "27.
|
|
41
|
-
"@ultimat3/money": "27.
|
|
42
|
-
"@ultimat3/policy": "27.
|
|
43
|
-
"@ultimat3/schema": "27.
|
|
44
|
-
"@ultimat3/time": "27.
|
|
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
|
}
|
package/src/agent-declared.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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. */
|
package/src/agent-errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/agent-facts.ts
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
65
|
-
import {
|
|
65
|
+
import type { LlmToolResult } from './tools';
|
|
66
|
+
import { isOfferedTool, runLlmToolCall, toolLabel } from './tools';
|
|
66
67
|
|
|
67
|
-
export type {
|
|
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) =>
|
|
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
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
-
//
|
|
158
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
...
|
|
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(
|
|
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.
|
package/src/errors-retry.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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';
|