@ultimat3/ai 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript one agent turn leaves behind: the assistant message replaying what the model
|
|
3
|
+
* emitted, and the user message answering it. One file because the Messages API's rule spans both
|
|
4
|
+
* halves — every `tool_use` block replayed here must be answered by a `tool_result` in the very
|
|
5
|
+
* next message — and a rule split across two call sites is a rule one of them will miss.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RESPOND } from './llm';
|
|
9
|
+
import type { AiContentBlock, AiMessage, GenerateResult } from './provider';
|
|
10
|
+
import type { LlmToolCall, LlmToolResult } from './tools';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
|
|
14
|
+
* as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
|
|
15
|
+
* because it has no `tool_result` to follow them with, and the API demands one.
|
|
16
|
+
*/
|
|
17
|
+
export function assistantTurn(result: GenerateResult): AiMessage {
|
|
18
|
+
const blocks: AiContentBlock[] = [];
|
|
19
|
+
if (result.text !== '') blocks.push({ type: 'text', text: result.text });
|
|
20
|
+
for (const call of result.toolCalls) {
|
|
21
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
|
|
22
|
+
}
|
|
23
|
+
// A turn with neither text nor a tool call would be an empty content array, which is a 400.
|
|
24
|
+
return blocks.length === 0
|
|
25
|
+
? { role: 'assistant', content: '(no answer)' }
|
|
26
|
+
: { role: 'assistant', content: blocks };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the model is told when it answered in the same turn it asked for a tool. Parallel tool use
|
|
31
|
+
* is normal, and the answer that comes with it was written before the tool result existed — so it
|
|
32
|
+
* is superseded, not wrong, and saying which is what stops the next turn repeating it verbatim.
|
|
33
|
+
*/
|
|
34
|
+
const SUPERSEDED = `This answer arrived in the same turn as the tool calls above, so it was written before their results existed. Read the results in this message, then call the "${RESPOND}" tool again.`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The tool results of one turn, plus a `tool_result` for every `respond` call the loop did not
|
|
38
|
+
* accept. The second half is not optional: `respond` is filtered out of the calls that get RUN,
|
|
39
|
+
* but it is replayed as a `tool_use` like any other, and a `tool_use` the next message does not
|
|
40
|
+
* answer is a 400 (`tool_use ids were found without tool_result blocks`) — the whole run lost to
|
|
41
|
+
* an `X_AI_PROVIDER_UNAVAILABLE` because the model did the ordinary thing.
|
|
42
|
+
*
|
|
43
|
+
* Real results stay first: they are what the model asked for, and the rejection reads as the
|
|
44
|
+
* instruction that follows them.
|
|
45
|
+
*/
|
|
46
|
+
export function toolResultTurn(
|
|
47
|
+
results: readonly LlmToolResult[],
|
|
48
|
+
chars: number,
|
|
49
|
+
unaccepted: readonly LlmToolCall[],
|
|
50
|
+
): AiMessage {
|
|
51
|
+
return {
|
|
52
|
+
role: 'user',
|
|
53
|
+
content: [
|
|
54
|
+
...results.map((result) => ({
|
|
55
|
+
type: 'tool_result' as const,
|
|
56
|
+
tool_use_id: result.toolUseId,
|
|
57
|
+
content: truncate(result.content, chars),
|
|
58
|
+
// A denial or a failure is an outcome the model should read and react to, flagged so it
|
|
59
|
+
// does not read as data.
|
|
60
|
+
...(result.isError === true ? { is_error: true } : {}),
|
|
61
|
+
})),
|
|
62
|
+
...rejections(unaccepted, SUPERSEDED),
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The correction after an answer that failed its schema, in whichever shape the turn it corrects
|
|
69
|
+
* demands: a `tool_result` when the answer came through `respond` (the dominant path, and a
|
|
70
|
+
* `tool_use` the API insists on seeing answered), a plain user message when the model answered in
|
|
71
|
+
* prose and there is no id to name. Framework text, so never truncated by `maxToolResultChars`.
|
|
72
|
+
*/
|
|
73
|
+
export function repairTurn(unaccepted: readonly LlmToolCall[], issues: string): AiMessage {
|
|
74
|
+
const text = `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
|
|
75
|
+
return unaccepted.length === 0
|
|
76
|
+
? { role: 'user', content: text }
|
|
77
|
+
: { role: 'user', content: rejections(unaccepted, text) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A `tool_result` saying an answer was not taken, flagged so the model does not read it as data. */
|
|
81
|
+
function rejections(calls: readonly LlmToolCall[], text: string): readonly AiContentBlock[] {
|
|
82
|
+
return calls.map((call) => ({
|
|
83
|
+
type: 'tool_result' as const,
|
|
84
|
+
tool_use_id: call.id,
|
|
85
|
+
content: text,
|
|
86
|
+
is_error: true,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
|
|
91
|
+
function truncate(text: string, chars: number): string {
|
|
92
|
+
if (text.length <= chars) return text;
|
|
93
|
+
return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
|
|
94
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent()` — a multi-turn tool-using model call, declared as an `action`.
|
|
3
|
+
*
|
|
4
|
+
* The third instance of the framework's rule, after `llm()` and `backfill()`: a new capability
|
|
5
|
+
* arrives as a FACTORY over an existing primitive, never as a ninth kind. A tool-using run is
|
|
6
|
+
* still one server-authoritative operation with an input schema, an output schema and a policy —
|
|
7
|
+
* so this returns an `action`, and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`,
|
|
8
|
+
* `.contract()` and its manifest row without a line here.
|
|
9
|
+
*
|
|
10
|
+
* It exists because the alternative is a hand-rolled loop outside the framework, and a hand-rolled
|
|
11
|
+
* loop is where the dangerous mistake lives: taking the ACTOR from the model's output. Here the
|
|
12
|
+
* actor is `ctx.actor` and nothing the model emits can reach it — `runLlmToolCall` is handed an
|
|
13
|
+
* identity the request established, and the tool it runs is an ordinary action whose own policy
|
|
14
|
+
* decides. There is no "LLM permissions" concept, because there is no second authz system.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NOT here: a semantic cache. Similar prompts do not have similar answers once the
|
|
17
|
+
* answer depends on what `lookupOrder` returned this second, and a cache over that would serve
|
|
18
|
+
* one run's world state to another. Version bump plus the tools' own caching is the story.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
22
|
+
import { action } from '@ultimat3/action';
|
|
23
|
+
import type { Ctx, Span } from '@ultimat3/core';
|
|
24
|
+
import { isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
|
|
25
|
+
import type { Money } from '@ultimat3/money';
|
|
26
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
27
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
28
|
+
import type { AgentFact } from './agent-facts';
|
|
29
|
+
import { registerAgentFact } from './agent-facts';
|
|
30
|
+
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
31
|
+
import type { BudgetLimits } from './budget';
|
|
32
|
+
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
33
|
+
import {
|
|
34
|
+
AgentMaxTurnsError,
|
|
35
|
+
AgentToolUnexposedError,
|
|
36
|
+
LlmOutputInvalidError,
|
|
37
|
+
LlmRefusedError,
|
|
38
|
+
LlmTruncatedError,
|
|
39
|
+
} from './errors';
|
|
40
|
+
import type { LlmBudget } from './llm';
|
|
41
|
+
import { answerAttributes, RESPOND, respondToolFor, structuredOutputOf } from './llm';
|
|
42
|
+
import type { ModelId } from './models';
|
|
43
|
+
import { DEFAULT_MODEL, moreCapableThan } from './models';
|
|
44
|
+
import type { Prompt, PromptVars } from './prompt';
|
|
45
|
+
import type {
|
|
46
|
+
AiMessage,
|
|
47
|
+
GenerateRequest,
|
|
48
|
+
GenerateResult,
|
|
49
|
+
StopReason,
|
|
50
|
+
TokenUsage,
|
|
51
|
+
} from './provider';
|
|
52
|
+
import { assertNoSecrets } from './redaction';
|
|
53
|
+
import { aiGateway, aiRedactor } from './runtime';
|
|
54
|
+
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
55
|
+
import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
|
|
59
|
+
* is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
|
|
60
|
+
* grows quadratically in turns rather than linearly.
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_MAX_TURNS = 8;
|
|
63
|
+
|
|
64
|
+
/** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
|
|
65
|
+
const DEFAULT_MAX_TOKENS = 4_096;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
|
|
69
|
+
* spends the whole context window on turn two and every later turn re-sends it — the transcript
|
|
70
|
+
* is the request, so an untruncated result is billed once per remaining turn.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_TOOL_RESULT_CHARS = 4_000;
|
|
73
|
+
|
|
74
|
+
export interface AgentBudget extends LlmBudget {
|
|
75
|
+
/**
|
|
76
|
+
* Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
|
|
77
|
+
* a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
|
|
78
|
+
*/
|
|
79
|
+
readonly tokensPerRun?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
|
|
83
|
+
readonly input: InferOutput<TInput>;
|
|
84
|
+
readonly ctx: Ctx;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AgentDef<
|
|
88
|
+
TInput extends StandardSchemaV1,
|
|
89
|
+
TOutput extends StandardSchemaV1,
|
|
90
|
+
V extends PromptVars,
|
|
91
|
+
> {
|
|
92
|
+
readonly model?: ModelId;
|
|
93
|
+
readonly input: TInput;
|
|
94
|
+
readonly output: TOutput;
|
|
95
|
+
readonly prompt: Prompt<V>;
|
|
96
|
+
/** Same contract as `llm()`: the one declared place a run loads data. */
|
|
97
|
+
vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
|
|
98
|
+
/**
|
|
99
|
+
* The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
|
|
100
|
+
* external MCP client is filtered by, so an in-app agent and an external one are offered
|
|
101
|
+
* exactly the same tools. Listing one that is not exposed is refused at declaration rather than
|
|
102
|
+
* dropped, because a tool that reads as offered and silently is not is the worst of both.
|
|
103
|
+
*/
|
|
104
|
+
readonly tools: readonly AgentTool[];
|
|
105
|
+
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
106
|
+
readonly maxTurns?: number;
|
|
107
|
+
readonly maxToolResultChars?: number;
|
|
108
|
+
readonly budget?: AgentBudget;
|
|
109
|
+
readonly policy: ActionPolicy;
|
|
110
|
+
readonly mcp?: ActionMcp;
|
|
111
|
+
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
112
|
+
readonly maxTokens?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
115
|
+
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
116
|
+
* it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
|
|
117
|
+
* hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
|
|
118
|
+
*
|
|
119
|
+
* Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
|
|
120
|
+
* actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
|
|
121
|
+
* run's own path, and an observer that silently stopped working would be indistinguishable from
|
|
122
|
+
* one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
|
|
123
|
+
* declares no hook is still readable in a trace.
|
|
124
|
+
*
|
|
125
|
+
* NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
|
|
126
|
+
* one turn deep by construction; this is per TURN.
|
|
127
|
+
*/
|
|
128
|
+
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
|
|
132
|
+
export interface AgentTurn {
|
|
133
|
+
/** 1-based, so `turn === maxTurns` is the last one this run will take. */
|
|
134
|
+
readonly turn: number;
|
|
135
|
+
readonly maxTurns: number;
|
|
136
|
+
readonly model: ModelId;
|
|
137
|
+
/** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
|
|
138
|
+
readonly toolCalls: readonly string[];
|
|
139
|
+
readonly stopReason: StopReason;
|
|
140
|
+
readonly usage: TokenUsage;
|
|
141
|
+
/** This turn's cost alone, integer minor units — never the run's running total. */
|
|
142
|
+
readonly cost: Money;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function agent<
|
|
146
|
+
TInput extends StandardSchemaV1,
|
|
147
|
+
TOutput extends StandardSchemaV1,
|
|
148
|
+
V extends PromptVars,
|
|
149
|
+
>(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
|
|
150
|
+
const respond = respondToolFor(def.output);
|
|
151
|
+
// At declaration, because the tools are values by then and a run that discovers this at the
|
|
152
|
+
// first request has already been declared, registered and projected as if it worked. Asked of
|
|
153
|
+
// the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
|
|
154
|
+
// `action()` beside this one in the same module has none until `registerActions` runs at boot.
|
|
155
|
+
const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
|
|
156
|
+
if (unexposed.length > 0) {
|
|
157
|
+
throw new AgentToolUnexposedError({
|
|
158
|
+
agent: def.prompt.ref,
|
|
159
|
+
tools: unexposed.map(toolLabel),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
|
|
163
|
+
// scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
|
|
164
|
+
// the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
165
|
+
let adapted: Adapted | undefined;
|
|
166
|
+
const adapt = (): Adapted => {
|
|
167
|
+
if (adapted === undefined) adapted = project(def.tools);
|
|
168
|
+
return adapted;
|
|
169
|
+
};
|
|
170
|
+
const built = action<TInput, TOutput>({
|
|
171
|
+
input: def.input,
|
|
172
|
+
output: def.output,
|
|
173
|
+
policy: def.policy,
|
|
174
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
175
|
+
handle: (args) => run(def, respond, adapt(), args),
|
|
176
|
+
});
|
|
177
|
+
// A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
|
|
178
|
+
// line runs at module scope.
|
|
179
|
+
registerAgentFact(built, () => factsOf(def));
|
|
180
|
+
return built;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function factsOf<
|
|
184
|
+
TInput extends StandardSchemaV1,
|
|
185
|
+
TOutput extends StandardSchemaV1,
|
|
186
|
+
V extends PromptVars,
|
|
187
|
+
>(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
|
|
188
|
+
const budget = def.budget;
|
|
189
|
+
return {
|
|
190
|
+
prompt: def.prompt.ref,
|
|
191
|
+
promptId: def.prompt.id,
|
|
192
|
+
promptHash: def.prompt.hash,
|
|
193
|
+
model: def.model ?? def.prompt.model ?? DEFAULT_MODEL,
|
|
194
|
+
maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
195
|
+
maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
196
|
+
tools: [...def.tools.map(toolLabel)].sort(),
|
|
197
|
+
budget: {
|
|
198
|
+
tokensIn: budget?.tokensIn ?? null,
|
|
199
|
+
tokensPerRun: budget?.tokensPerRun ?? null,
|
|
200
|
+
costPerCall: budget?.costPerCall ?? null,
|
|
201
|
+
},
|
|
202
|
+
mcp: isMcpExposed(def.mcp),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
|
|
207
|
+
interface Adapted {
|
|
208
|
+
readonly tools: readonly ProjectableAction[];
|
|
209
|
+
readonly offered: readonly LlmTool[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function project(tools: readonly AgentTool[]): Adapted {
|
|
213
|
+
const projected = tools.map(asProjectableAction);
|
|
214
|
+
return { tools: projected, offered: toLlmTools(projected) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function run<
|
|
218
|
+
TInput extends StandardSchemaV1,
|
|
219
|
+
TOutput extends StandardSchemaV1,
|
|
220
|
+
V extends PromptVars,
|
|
221
|
+
>(
|
|
222
|
+
def: AgentDef<TInput, TOutput, V>,
|
|
223
|
+
respond: LlmTool,
|
|
224
|
+
adapted: Adapted,
|
|
225
|
+
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
226
|
+
): Promise<InferOutput<TOutput>> {
|
|
227
|
+
const { prompt } = def;
|
|
228
|
+
const name = prompt.ref;
|
|
229
|
+
const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
|
|
230
|
+
const vars = await def.vars({ input: args.input, ctx: args.ctx });
|
|
231
|
+
assertNoSecrets(name, vars);
|
|
232
|
+
const redact = aiRedactor();
|
|
233
|
+
const rawPrompt = prompt.render(vars);
|
|
234
|
+
const rendered = redact(rawPrompt);
|
|
235
|
+
const system = prompt.system === undefined ? undefined : redact(prompt.system);
|
|
236
|
+
const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
237
|
+
const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
|
|
238
|
+
|
|
239
|
+
return withSpan('ai.agent', async (span) => {
|
|
240
|
+
span.setAttributes({
|
|
241
|
+
'agent.model': model,
|
|
242
|
+
'agent.prompt': name,
|
|
243
|
+
'agent.prompt.hash': prompt.hash,
|
|
244
|
+
'agent.tools': adapted.offered.length,
|
|
245
|
+
'agent.max_turns': maxTurns,
|
|
246
|
+
'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
|
|
250
|
+
const gateway = aiGateway(name);
|
|
251
|
+
const base: GenerateRequest = {
|
|
252
|
+
model,
|
|
253
|
+
...(system === undefined ? {} : { system }),
|
|
254
|
+
messages: [],
|
|
255
|
+
maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
256
|
+
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
257
|
+
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
258
|
+
tools: [...adapted.offered, respond],
|
|
259
|
+
// The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
|
|
260
|
+
// not just the top of the next turn: a provider call already in flight is the expensive one.
|
|
261
|
+
signal: args.ctx.signal,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return withBudget(ledger, async () => {
|
|
265
|
+
// The actor is read ONCE, from the context the request established, and is the only
|
|
266
|
+
// identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
|
|
267
|
+
// name the identity it acts as, and a loop that let it would be an escalation primitive.
|
|
268
|
+
const { actor } = args.ctx;
|
|
269
|
+
let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
|
|
270
|
+
let calls = 0;
|
|
271
|
+
let issues: string | undefined;
|
|
272
|
+
|
|
273
|
+
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
274
|
+
// The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
|
|
275
|
+
// transcript, so a loop that keeps going after a disconnect spends the rest of the run's
|
|
276
|
+
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
277
|
+
throwIfAborted(args.ctx);
|
|
278
|
+
const result = await gateway.generate({ ...base, messages });
|
|
279
|
+
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
280
|
+
// The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
|
|
281
|
+
// `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
|
|
282
|
+
const answers = result.toolCalls.filter((call) => call.name === RESPOND);
|
|
283
|
+
span.setAttributes({
|
|
284
|
+
'agent.turns': turn,
|
|
285
|
+
'agent.tool_calls': calls,
|
|
286
|
+
...answerAttributes(result),
|
|
287
|
+
});
|
|
288
|
+
await observeTurn(def, span, {
|
|
289
|
+
turn,
|
|
290
|
+
maxTurns,
|
|
291
|
+
model: result.model,
|
|
292
|
+
toolCalls: requested.map((call) => call.name),
|
|
293
|
+
stopReason: result.stopReason,
|
|
294
|
+
usage: result.usage,
|
|
295
|
+
cost: result.cost,
|
|
296
|
+
});
|
|
297
|
+
assertAnswerable(result, name);
|
|
298
|
+
|
|
299
|
+
if (requested.length > 0) {
|
|
300
|
+
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
301
|
+
// model turn asked for, each entry is an ordinary `action` carrying its own policy and
|
|
302
|
+
// its own `rateLimit`, and a second ceiling here would be a throttle competing with
|
|
303
|
+
// those. A tool that calls a model still queues on the ledger's root turnstile, so the
|
|
304
|
+
// budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
|
|
305
|
+
// in the types or the docs ever said so.
|
|
306
|
+
//
|
|
307
|
+
// Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
|
|
308
|
+
// result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
|
|
309
|
+
// first cannot be paired with a slow tool's call.
|
|
310
|
+
throwIfAborted(args.ctx);
|
|
311
|
+
const results: readonly LlmToolResult[] = await Promise.all(
|
|
312
|
+
requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
|
|
313
|
+
);
|
|
314
|
+
calls += requested.length;
|
|
315
|
+
messages = [
|
|
316
|
+
...messages,
|
|
317
|
+
assistantTurn(result),
|
|
318
|
+
// Parallel tool use — one turn asking for a tool AND answering — is normal, and the
|
|
319
|
+
// answer is speculative: it was written before the result it asked for existed. So
|
|
320
|
+
// the loop continues, and the answer is REJECTED on the wire rather than dropped,
|
|
321
|
+
// because a replayed `respond` block with no `tool_result` is a 400 and the whole run
|
|
322
|
+
// becomes an `X_AI_PROVIDER_UNAVAILABLE`.
|
|
323
|
+
toolResultTurn(results, chars, answers),
|
|
324
|
+
];
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const parsed = await validateAsync(def.output, structuredOutputOf(result));
|
|
329
|
+
if (parsed.issues === undefined) return parsed.value;
|
|
330
|
+
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
331
|
+
// turns left by construction — and unlike a tool result, the correction is the message.
|
|
332
|
+
issues = formatIssues(parsed.issues).join('; ');
|
|
333
|
+
if (result.stopReason === 'max_tokens') {
|
|
334
|
+
throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
|
|
335
|
+
}
|
|
336
|
+
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Two different exhaustions, so two different causes: a loop that kept calling tools and
|
|
340
|
+
// never answered is not the same event as one that answered the wrong shape every time.
|
|
341
|
+
if (issues !== undefined) {
|
|
342
|
+
throw new LlmOutputInvalidError({ prompt: name, attempts: maxTurns, issues });
|
|
343
|
+
}
|
|
344
|
+
throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One turn, reported twice: onto the span every run already opens, and to the declaration's own
|
|
351
|
+
* hook. The span event is free — nothing to install, and a trace is where a stalled run is
|
|
352
|
+
* actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
|
|
353
|
+
*
|
|
354
|
+
* Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
|
|
355
|
+
* path, and an observer that quietly stopped working reads exactly like one that is fine.
|
|
356
|
+
*/
|
|
357
|
+
async function observeTurn<
|
|
358
|
+
TInput extends StandardSchemaV1,
|
|
359
|
+
TOutput extends StandardSchemaV1,
|
|
360
|
+
V extends PromptVars,
|
|
361
|
+
>(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
|
|
362
|
+
span.addEvent('agent.turn', {
|
|
363
|
+
'agent.turn': event.turn,
|
|
364
|
+
'agent.turn.tool_calls': event.toolCalls.length,
|
|
365
|
+
'llm.stop': event.stopReason,
|
|
366
|
+
'llm.cost.minor': event.cost.minor,
|
|
367
|
+
});
|
|
368
|
+
await def.onTurn?.(event);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
|
|
372
|
+
function assertAnswerable(result: GenerateResult, name: string): void {
|
|
373
|
+
if (result.stopReason !== 'refusal') return;
|
|
374
|
+
throw new LlmRefusedError({
|
|
375
|
+
prompt: name,
|
|
376
|
+
model: result.model,
|
|
377
|
+
alternative: moreCapableThan(result.model),
|
|
378
|
+
category: result.stopDetails?.category,
|
|
379
|
+
explanation: result.stopDetails?.explanation,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function limitsOf<
|
|
384
|
+
TInput extends StandardSchemaV1,
|
|
385
|
+
TOutput extends StandardSchemaV1,
|
|
386
|
+
V extends PromptVars,
|
|
387
|
+
>(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
|
|
388
|
+
const budget = def.budget;
|
|
389
|
+
return {
|
|
390
|
+
...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
|
|
391
|
+
...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
|
|
392
|
+
// The ledger's `request` scope accumulates across every call made under one ledger, which for
|
|
393
|
+
// a run under `withBudget` is exactly "the whole run".
|
|
394
|
+
...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
|
|
395
|
+
};
|
|
396
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -62,10 +62,20 @@ export function estimateSpend(request: GenerateRequest): SpendEstimate {
|
|
|
62
62
|
/** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
|
|
63
63
|
export interface BudgetStore {
|
|
64
64
|
spent(key: string): Promise<number> | number;
|
|
65
|
+
/** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
|
|
65
66
|
add(key: string, tokens: number): Promise<void> | void;
|
|
66
67
|
reset(key?: string): Promise<void> | void;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* What `reserve` debited, so `record` can reconcile it against the provider's real counts and
|
|
72
|
+
* `release` can give it back. Held by the caller rather than the ledger because one ledger serves
|
|
73
|
+
* every concurrent call in a request, and each one owns its own reservation.
|
|
74
|
+
*/
|
|
75
|
+
export interface BudgetReservation {
|
|
76
|
+
readonly tokens: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
export class MemoryBudgetStore implements BudgetStore {
|
|
70
80
|
private readonly counters = new Map<string, number>();
|
|
71
81
|
|
|
@@ -108,6 +118,23 @@ export class BudgetLedger {
|
|
|
108
118
|
private requestTokens = 0;
|
|
109
119
|
private costMinor = 0;
|
|
110
120
|
private readonly currency: string;
|
|
121
|
+
/**
|
|
122
|
+
* The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
|
|
123
|
+
* rather than taken through `BudgetLedgerInput`, so the chain is always the derivation and a
|
|
124
|
+
* caller cannot build a cycle out of it.
|
|
125
|
+
*
|
|
126
|
+
* Without it a derived ledger reported to nobody: `llm()` derives one per call, so the ambient
|
|
127
|
+
* ledger `gateway.scope()` installed counted zero tokens and zero cost however many calls ran
|
|
128
|
+
* inside it, and its `request` ceiling was re-granted in full to every one of them.
|
|
129
|
+
*/
|
|
130
|
+
private parent: BudgetLedger | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Reservations take turns. Check-then-debit spans an `await store.spent()`, and three callers
|
|
133
|
+
* interleaving inside it is the bypass this ledger exists to close — one event loop, so a
|
|
134
|
+
* promise chain IS the lock. A store shared across PROCESSES needs an atomic increment of its
|
|
135
|
+
* own; this closes the parallelism inside one.
|
|
136
|
+
*/
|
|
137
|
+
private turnstile: Promise<unknown> = Promise.resolve();
|
|
111
138
|
|
|
112
139
|
constructor(input: BudgetLedgerInput) {
|
|
113
140
|
this.limits = input.limits;
|
|
@@ -118,12 +145,40 @@ export class BudgetLedger {
|
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
/**
|
|
121
|
-
* Check an estimate against every applicable scope BEFORE the call. Throws on
|
|
122
|
-
* scope that cannot cover it, naming that scope, so the fix line points at one knob
|
|
123
|
-
* than four.
|
|
148
|
+
* Check an estimate against every applicable scope BEFORE the call, then DEBIT it. Throws on
|
|
149
|
+
* the first scope that cannot cover it, naming that scope, so the fix line points at one knob
|
|
150
|
+
* rather than four.
|
|
151
|
+
*
|
|
152
|
+
* The debit is what makes the ceiling hold under parallelism. Checking without debiting meant
|
|
153
|
+
* three concurrent calls under one ledger all read `spent() === 0`, all passed, and all three
|
|
154
|
+
* recorded against a ceiling only one of them fitted — an "un-bypassable" org budget bypassed
|
|
155
|
+
* by `Promise.all`. `record` replaces the estimate with the real counts; `release` gives it
|
|
156
|
+
* back when the call never happened.
|
|
124
157
|
*/
|
|
125
|
-
async reserve(estimate: SpendEstimate): Promise<
|
|
126
|
-
|
|
158
|
+
async reserve(estimate: SpendEstimate): Promise<BudgetReservation> {
|
|
159
|
+
// The ROOT's turnstile, not this ledger's: reservations under one scope take turns even when
|
|
160
|
+
// each call derived its own ledger, which is every `llm()` call. A per-ledger queue serialised
|
|
161
|
+
// nothing once `derive` existed — `Promise.all` of three derived ledgers all read the chain
|
|
162
|
+
// before any of them debited it.
|
|
163
|
+
const gate = this.rootLedger();
|
|
164
|
+
const turn = gate.turnstile.then(() => this.reserveNow(estimate));
|
|
165
|
+
// Chained on a settled shadow: one refusal must not reject every reservation queued behind it.
|
|
166
|
+
gate.turnstile = turn.catch(() => undefined);
|
|
167
|
+
return await turn;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private rootLedger(): BudgetLedger {
|
|
171
|
+
let ledger: BudgetLedger = this;
|
|
172
|
+
while (ledger.parent !== undefined) ledger = ledger.parent;
|
|
173
|
+
return ledger;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async reserveNow(estimate: SpendEstimate): Promise<BudgetReservation> {
|
|
177
|
+
// Every ledger in the chain, because each keeps its own counter and the tightest limit is not
|
|
178
|
+
// always the one with the most spent against it.
|
|
179
|
+
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
180
|
+
l.assertScope('request', l.limits.request, l.requestTokens, estimate.tokens);
|
|
181
|
+
}
|
|
127
182
|
// Per call, so nothing is "already spent" against it.
|
|
128
183
|
this.assertScope('tokensIn', this.limits.tokensIn, 0, estimate.inputTokens);
|
|
129
184
|
if (this.limits.actor !== undefined && this.actorKey !== undefined) {
|
|
@@ -135,6 +190,14 @@ export class BudgetLedger {
|
|
|
135
190
|
this.assertScope(`org:${this.orgKey}`, this.limits.org, spent, estimate.tokens);
|
|
136
191
|
}
|
|
137
192
|
this.assertCost(estimate.cost);
|
|
193
|
+
await this.debit(estimate.tokens);
|
|
194
|
+
return { tokens: estimate.tokens };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Give a reservation back: a provider that threw, a stream abandoned before `done`. */
|
|
198
|
+
async release(reservation: BudgetReservation | undefined): Promise<void> {
|
|
199
|
+
if (reservation === undefined) return;
|
|
200
|
+
await this.debit(-reservation.tokens);
|
|
138
201
|
}
|
|
139
202
|
|
|
140
203
|
/**
|
|
@@ -143,7 +206,7 @@ export class BudgetLedger {
|
|
|
143
206
|
* an `llm()` action must not be able to widen the actor or org ceiling it runs inside.
|
|
144
207
|
*/
|
|
145
208
|
derive(limits: BudgetLimits): BudgetLedger {
|
|
146
|
-
|
|
209
|
+
const child = new BudgetLedger({
|
|
147
210
|
limits: {
|
|
148
211
|
...pick('request', tighterNumber(this.limits.request, limits.request)),
|
|
149
212
|
...pick('tokensIn', tighterNumber(this.limits.tokensIn, limits.tokensIn)),
|
|
@@ -156,13 +219,37 @@ export class BudgetLedger {
|
|
|
156
219
|
store: this.store,
|
|
157
220
|
currency: this.currency,
|
|
158
221
|
});
|
|
222
|
+
child.parent = this;
|
|
223
|
+
return child;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from — so only
|
|
228
|
+
* the DIFFERENCE lands here. Called without the reservation it behaves as it always did and
|
|
229
|
+
* debits the full amount, which double-counts a reserved call: pass the handle `reserve`
|
|
230
|
+
* returned.
|
|
231
|
+
*/
|
|
232
|
+
async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
|
|
233
|
+
// Up the chain, because `derive` copies the currency: a scope's reported cost is its own
|
|
234
|
+
// calls plus every call made under a ledger derived from it.
|
|
235
|
+
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
236
|
+
l.costMinor += cost.minor;
|
|
237
|
+
}
|
|
238
|
+
await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
|
|
159
239
|
}
|
|
160
240
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
/**
|
|
242
|
+
* The one write path. Negative credits a release or an over-estimate back.
|
|
243
|
+
*
|
|
244
|
+
* The in-memory counters walk the chain; the STORE is written once, by the ledger the call was
|
|
245
|
+
* made on. A child shares its parent's store and identity keys, so debiting through the parent
|
|
246
|
+
* as well would bill the actor and the org twice for one call.
|
|
247
|
+
*/
|
|
248
|
+
private async debit(tokens: number): Promise<void> {
|
|
249
|
+
if (tokens === 0) return;
|
|
250
|
+
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
251
|
+
l.requestTokens += tokens;
|
|
252
|
+
}
|
|
166
253
|
if (this.actorKey !== undefined) await this.store.add(this.actorKey, tokens);
|
|
167
254
|
if (this.orgKey !== undefined) await this.store.add(this.orgKey, tokens);
|
|
168
255
|
}
|