@ultimat3/ai 27.8.1 → 27.10.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 +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/agent.ts
CHANGED
|
@@ -20,169 +20,73 @@
|
|
|
20
20
|
* one run's world state to another. Version bump plus the tools' own caching is the story.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import type { Action
|
|
23
|
+
import type { Action } from '@ultimat3/action';
|
|
24
24
|
import { action } from '@ultimat3/action';
|
|
25
|
-
import type { Ctx
|
|
26
|
-
import {
|
|
27
|
-
import type { Money } from '@ultimat3/money';
|
|
25
|
+
import type { Ctx } from '@ultimat3/core';
|
|
26
|
+
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
28
27
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
29
28
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
30
|
-
import type {
|
|
29
|
+
import type { AgentAction, TranscriptBinding } from './agent-continue';
|
|
30
|
+
import {
|
|
31
|
+
claimAgentSession,
|
|
32
|
+
continued,
|
|
33
|
+
screenTranscript,
|
|
34
|
+
sealTranscript,
|
|
35
|
+
sessionCalls,
|
|
36
|
+
withTranscript,
|
|
37
|
+
} from './agent-continue';
|
|
38
|
+
import type { AgentBounds } from './agent-declared';
|
|
39
|
+
import { factsOf, limitsOf, SUBJECT, screenBounds } from './agent-declared';
|
|
40
|
+
import type { AgentDef } from './agent-def';
|
|
41
|
+
import { AgentTranscriptInvalidError } from './agent-errors';
|
|
31
42
|
import { registerAgentFact } from './agent-facts';
|
|
43
|
+
import { runDeadline, toolLoopGuard } from './agent-guards';
|
|
44
|
+
import { sameSystem, systemOf } from './agent-system';
|
|
32
45
|
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
33
|
-
import
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
LlmOutputInvalidError,
|
|
39
|
-
LlmRefusedError,
|
|
40
|
-
LlmTruncatedError,
|
|
41
|
-
} from './errors';
|
|
42
|
-
import type { LlmBudget } from './llm';
|
|
46
|
+
import { assertAnswerable, observeTurn } from './agent-turn';
|
|
47
|
+
import { runTotal } from './agent-usage';
|
|
48
|
+
import { LlmTruncatedError } from './answer-errors';
|
|
49
|
+
import { budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
50
|
+
import { AgentMaxTurnsError, AgentToolUnexposedError, LlmOutputInvalidError } from './errors';
|
|
43
51
|
import { answerAttributes } from './llm';
|
|
44
|
-
import {
|
|
45
|
-
import type {
|
|
46
|
-
import {
|
|
47
|
-
import type {
|
|
48
|
-
import type {
|
|
49
|
-
AiMessage,
|
|
50
|
-
GenerateRequest,
|
|
51
|
-
GenerateResult,
|
|
52
|
-
StopReason,
|
|
53
|
-
TokenUsage,
|
|
54
|
-
} from './provider';
|
|
52
|
+
import { modelForCall } from './model-resolve';
|
|
53
|
+
import type { PromptVars } from './prompt';
|
|
54
|
+
import { promptCacheHint } from './prompt-cache';
|
|
55
|
+
import type { AiMessage, GenerateRequest } from './provider';
|
|
55
56
|
import { isTruncated } from './provider';
|
|
56
57
|
import { assertNoSecrets } from './redaction';
|
|
57
58
|
import type { Respond } from './respond';
|
|
58
59
|
import { RESPOND, respondFor } from './respond';
|
|
60
|
+
import { runMeter } from './run-meter';
|
|
59
61
|
import { aiGateway, aiRedactor, installedGateway } from './runtime';
|
|
62
|
+
import type { Sampling } from './sampling';
|
|
63
|
+
import { ignoredSampling, screenSampling } from './sampling';
|
|
60
64
|
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
61
|
-
import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
|
|
65
|
-
* is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
|
|
66
|
-
* grows quadratically in turns rather than linearly.
|
|
67
|
-
*/
|
|
68
|
-
const DEFAULT_MAX_TURNS = 8;
|
|
69
|
-
|
|
70
|
-
/** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
|
|
71
|
-
const DEFAULT_MAX_TOKENS = 4_096;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
|
|
75
|
-
* spends the whole context window on turn two and every later turn re-sends it — the transcript
|
|
76
|
-
* is the request, so an untruncated result is billed once per remaining turn.
|
|
77
|
-
*/
|
|
78
|
-
const DEFAULT_TOOL_RESULT_CHARS = 4_000;
|
|
79
|
-
|
|
80
|
-
/** Named in every bound refusal, so the fix names the key the declaration carries. */
|
|
81
|
-
const SUBJECT = 'agent';
|
|
82
|
-
|
|
83
|
-
export interface AgentBudget extends LlmBudget {
|
|
84
|
-
/**
|
|
85
|
-
* Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
|
|
86
|
-
* a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
|
|
87
|
-
*/
|
|
88
|
-
readonly tokensPerRun?: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
|
|
92
|
-
readonly input: InferOutput<TInput>;
|
|
93
|
-
readonly ctx: Ctx;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface AgentDef<
|
|
97
|
-
TInput extends StandardSchemaV1,
|
|
98
|
-
TOutput extends StandardSchemaV1,
|
|
99
|
-
V extends PromptVars,
|
|
100
|
-
> {
|
|
101
|
-
readonly model?: ModelId;
|
|
102
|
-
readonly input: TInput;
|
|
103
|
-
readonly output: TOutput;
|
|
104
|
-
readonly prompt: Prompt<V>;
|
|
105
|
-
/** Same contract as `llm()`: the one declared place a run loads data. */
|
|
106
|
-
vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
|
|
107
|
-
/**
|
|
108
|
-
* The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
|
|
109
|
-
* external MCP client is filtered by, so an in-app agent and an external one are offered
|
|
110
|
-
* exactly the same tools. Listing one that is not exposed is refused at declaration rather than
|
|
111
|
-
* dropped, because a tool that reads as offered and silently is not is the worst of both.
|
|
112
|
-
*/
|
|
113
|
-
readonly tools: readonly AgentTool[];
|
|
114
|
-
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
115
|
-
readonly maxTurns?: number;
|
|
116
|
-
readonly maxToolResultChars?: number;
|
|
117
|
-
readonly budget?: AgentBudget;
|
|
118
|
-
readonly policy: ActionPolicy;
|
|
119
|
-
readonly mcp?: ActionMcp;
|
|
120
|
-
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
121
|
-
readonly maxTokens?: number;
|
|
122
|
-
/**
|
|
123
|
-
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
124
|
-
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
125
|
-
* it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
|
|
126
|
-
* hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
|
|
127
|
-
*
|
|
128
|
-
* Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
|
|
129
|
-
* actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
|
|
130
|
-
* run's own path, and an observer that silently stopped working would be indistinguishable from
|
|
131
|
-
* one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
|
|
132
|
-
* declares no hook is still readable in a trace.
|
|
133
|
-
*
|
|
134
|
-
* NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
|
|
135
|
-
* one turn deep by construction; this is per TURN.
|
|
136
|
-
*/
|
|
137
|
-
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
138
|
-
}
|
|
65
|
+
import { asProjectableAction, isOfferedTool, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
139
66
|
|
|
140
|
-
|
|
141
|
-
export interface AgentTurn {
|
|
142
|
-
/** 1-based, so `turn === maxTurns` is the last one this run will take. */
|
|
143
|
-
readonly turn: number;
|
|
144
|
-
readonly maxTurns: number;
|
|
145
|
-
readonly model: ModelId;
|
|
146
|
-
/** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
|
|
147
|
-
readonly toolCalls: readonly string[];
|
|
148
|
-
readonly stopReason: StopReason;
|
|
149
|
-
readonly usage: TokenUsage;
|
|
150
|
-
/** This turn's cost alone, integer minor units — never the run's running total. */
|
|
151
|
-
readonly cost: Money;
|
|
152
|
-
}
|
|
67
|
+
export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
|
|
153
68
|
|
|
154
69
|
export function agent<
|
|
155
70
|
TInput extends StandardSchemaV1,
|
|
156
71
|
TOutput extends StandardSchemaV1,
|
|
157
72
|
V extends PromptVars,
|
|
158
|
-
>(def: AgentDef<TInput, TOutput, V>):
|
|
73
|
+
>(def: AgentDef<TInput, TOutput, V>): AgentAction<TInput, TOutput> {
|
|
159
74
|
const respond = respondFor(def.output);
|
|
160
75
|
// At declaration, because the tools are values by then and a run that discovers this at the
|
|
161
76
|
// first request has already been declared, registered and projected as if it worked. Asked of
|
|
162
77
|
// the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
|
|
163
78
|
// `action()` beside this one in the same module has none until `registerActions` runs at boot.
|
|
164
|
-
const unexposed = def.tools.filter((tool) => !
|
|
79
|
+
const unexposed = def.tools.filter((tool) => !isOfferedTool(tool));
|
|
165
80
|
if (unexposed.length > 0) {
|
|
166
81
|
throw new AgentToolUnexposedError({
|
|
167
82
|
agent: def.prompt.ref,
|
|
168
83
|
tools: unexposed.map(toolLabel),
|
|
169
84
|
});
|
|
170
85
|
}
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// `X_AGENT_MAX_TURNS` about a model that was never called; `slice(0, NaN)` is `''`, so every
|
|
176
|
-
// tool result reaches the model empty while the run keeps paying for turns; and `maxTokens`
|
|
177
|
-
// becomes the pre-flight estimate, where a `NaN` disables the ledger for the whole process.
|
|
178
|
-
finiteCount(SUBJECT, 'maxTurns', def.maxTurns ?? DEFAULT_MAX_TURNS, 1);
|
|
179
|
-
finiteCount(
|
|
180
|
-
SUBJECT,
|
|
181
|
-
'maxToolResultChars',
|
|
182
|
-
def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
183
|
-
1,
|
|
184
|
-
);
|
|
185
|
-
finiteCount(SUBJECT, 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
|
|
86
|
+
const bounds = screenBounds(def);
|
|
87
|
+
// This declaration's identity for `.run()` / `.continue()` sessions (`claimAgentSession`).
|
|
88
|
+
const owner = {};
|
|
89
|
+
const sampling = screenSampling(SUBJECT, def);
|
|
186
90
|
// Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
|
|
187
91
|
// scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
|
|
188
92
|
// the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
@@ -196,48 +100,27 @@ export function agent<
|
|
|
196
100
|
output: def.output,
|
|
197
101
|
policy: def.policy,
|
|
198
102
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
199
|
-
handle: (args) => run(def, respond, adapt(), args),
|
|
103
|
+
handle: (args) => run(def, respond, adapt(), { ...bounds, sampling, owner }, args),
|
|
200
104
|
});
|
|
201
105
|
// A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
|
|
202
106
|
// line runs at module scope.
|
|
203
107
|
registerAgentFact(built, () => factsOf(def));
|
|
204
|
-
return built;
|
|
108
|
+
return continuable(built, owner);
|
|
205
109
|
}
|
|
206
110
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
V extends PromptVars,
|
|
221
|
-
>(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
|
|
222
|
-
const budget = def.budget;
|
|
223
|
-
return {
|
|
224
|
-
prompt: def.prompt.ref,
|
|
225
|
-
promptId: def.prompt.id,
|
|
226
|
-
promptHash: def.prompt.hash,
|
|
227
|
-
// The run's own precedence, so the row names the model a call would use NOW: read when the
|
|
228
|
-
// facts are, at describe time, like every name in the row. `modelFrom` says which place
|
|
229
|
-
// answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
|
|
230
|
-
...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
|
|
231
|
-
maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
232
|
-
maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
233
|
-
tools: [...def.tools.map(toolLabel)].sort(),
|
|
234
|
-
budget: {
|
|
235
|
-
tokensIn: budget?.tokensIn ?? null,
|
|
236
|
-
tokensPerRun: budget?.tokensPerRun ?? null,
|
|
237
|
-
costPerCall: budget?.costPerCall ?? null,
|
|
238
|
-
},
|
|
239
|
-
mcp: isMcpExposed(def.mcp),
|
|
240
|
-
};
|
|
111
|
+
/**
|
|
112
|
+
* Attach `.run()` and `.continue()` IN PLACE, for the reason `llm()`'s `streamable` gives: the
|
|
113
|
+
* object an app exported is the one the registry names, and `named()`'s twin gets them too.
|
|
114
|
+
*/
|
|
115
|
+
function continuable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
116
|
+
target: Action<TInput, TOutput>,
|
|
117
|
+
owner: object,
|
|
118
|
+
): AgentAction<TInput, TOutput> {
|
|
119
|
+
const rename = target.named.bind(target);
|
|
120
|
+
return Object.assign(target, {
|
|
121
|
+
...sessionCalls(target, owner),
|
|
122
|
+
named: (next: string): AgentAction<TInput, TOutput> => continuable(rename(next), owner),
|
|
123
|
+
});
|
|
241
124
|
}
|
|
242
125
|
|
|
243
126
|
/** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
|
|
@@ -259,22 +142,50 @@ async function run<
|
|
|
259
142
|
def: AgentDef<TInput, TOutput, V>,
|
|
260
143
|
respond: Respond,
|
|
261
144
|
adapted: Adapted,
|
|
145
|
+
bounds: AgentBounds & { readonly sampling: Sampling; readonly owner: object },
|
|
262
146
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
263
147
|
): Promise<InferOutput<TOutput>> {
|
|
264
148
|
const { prompt } = def;
|
|
265
149
|
const name = prompt.ref;
|
|
150
|
+
// A `.continue()` in flight: its handle is the app's stored data, so it is screened before
|
|
151
|
+
// anything is loaded, reserved or sent.
|
|
152
|
+
const session = claimAgentSession(bounds.owner);
|
|
153
|
+
const resume = session?.resume;
|
|
154
|
+
if (resume !== undefined && resume.user.trim() === '') {
|
|
155
|
+
throw new AgentTranscriptInvalidError({ agent: name, reason: 'the user turn is empty' });
|
|
156
|
+
}
|
|
266
157
|
// Screened first: a bad declaration is refused whether or not a gateway exists.
|
|
267
|
-
|
|
158
|
+
limitsOf(def);
|
|
268
159
|
// The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
|
|
269
160
|
const model = modelForCall('agent', name, def.model, prompt.model);
|
|
270
|
-
|
|
271
|
-
|
|
161
|
+
// What a handle is bound to, read off the declaration and the request — never off the handle.
|
|
162
|
+
const binding: TranscriptBinding = {
|
|
163
|
+
agent: name,
|
|
164
|
+
model,
|
|
165
|
+
tools: [...adapted.tools.map((tool) => tool.name), RESPOND],
|
|
166
|
+
org: args.ctx.actor.orgId ?? null,
|
|
167
|
+
};
|
|
168
|
+
const prior = resume === undefined ? undefined : screenTranscript(binding, resume.transcript);
|
|
169
|
+
const limits = limitsOf(def, prior?.usage);
|
|
170
|
+
// Armed before anything is loaded: `vars()` is part of the run a deadline bounds.
|
|
171
|
+
const deadline = runDeadline(name, def.deadlineMs, args.ctx.signal);
|
|
272
172
|
const redact = aiRedactor();
|
|
273
|
-
|
|
173
|
+
// A continuation does not render the prompt again: the transcript holds what was sent, and
|
|
174
|
+
// `vars()` — the load — is not run a second time. Its own turn is the caller's text.
|
|
175
|
+
let rawPrompt = resume?.user;
|
|
176
|
+
if (rawPrompt === undefined) {
|
|
177
|
+
const vars = await deadline.guard(Promise.resolve(def.vars(args)));
|
|
178
|
+
assertNoSecrets(name, vars);
|
|
179
|
+
rawPrompt = prompt.render(vars);
|
|
180
|
+
}
|
|
274
181
|
const rendered = redact(rawPrompt);
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
const
|
|
182
|
+
const extra =
|
|
183
|
+
def.system === undefined ? {} : await deadline.guard(Promise.resolve(def.system(args)));
|
|
184
|
+
const rawSystem = systemOf(prompt.system, extra, (text) => text);
|
|
185
|
+
const system = systemOf(prompt.system, extra, redact);
|
|
186
|
+
const promptCache = promptCacheHint(def.promptCache, 'auto', args);
|
|
187
|
+
const repeated = toolLoopGuard(name, bounds.maxRepeatedToolCalls);
|
|
188
|
+
const { maxTurns, maxToolResultChars: chars } = bounds;
|
|
278
189
|
|
|
279
190
|
return withSpan('ai.agent', async (span) => {
|
|
280
191
|
span.setAttributes({
|
|
@@ -283,7 +194,13 @@ async function run<
|
|
|
283
194
|
'agent.prompt.hash': prompt.hash,
|
|
284
195
|
'agent.tools': adapted.offered.length,
|
|
285
196
|
'agent.max_turns': maxTurns,
|
|
286
|
-
'llm.redacted': rendered !== rawPrompt || system
|
|
197
|
+
'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
|
|
198
|
+
'llm.prompt_cache': promptCache !== undefined,
|
|
199
|
+
'llm.sampling.ignored': ignoredSampling(
|
|
200
|
+
model,
|
|
201
|
+
bounds.sampling,
|
|
202
|
+
installedGateway()?.samplingWires?.(model),
|
|
203
|
+
).join(','),
|
|
287
204
|
});
|
|
288
205
|
|
|
289
206
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
@@ -295,31 +212,54 @@ async function run<
|
|
|
295
212
|
const base: GenerateRequest = {
|
|
296
213
|
model,
|
|
297
214
|
...(system === undefined ? {} : { system }),
|
|
215
|
+
...(promptCache === undefined ? {} : { promptCache }),
|
|
298
216
|
messages: [],
|
|
299
|
-
maxTokens:
|
|
217
|
+
maxTokens: bounds.maxTokens,
|
|
300
218
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
301
219
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
220
|
+
...bounds.sampling,
|
|
302
221
|
tools: [...adapted.offered, respond.tool],
|
|
303
222
|
// The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
|
|
304
223
|
// not just the top of the next turn: a provider call already in flight is the expensive one.
|
|
305
|
-
signal
|
|
224
|
+
// The run's deadline is folded into the same signal, so it reaches the socket too.
|
|
225
|
+
signal: deadline.signal,
|
|
306
226
|
};
|
|
307
227
|
|
|
308
|
-
|
|
228
|
+
// Every provider call below goes through the meter, so whatever ends the run — a ceiling, a
|
|
229
|
+
// deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
|
|
230
|
+
const meter = runMeter();
|
|
231
|
+
const disclosure = { discloseErrors: def.discloseToolErrors !== false };
|
|
232
|
+
// Where a failure would resume from: the request in flight, and the turns answered so far.
|
|
233
|
+
const at = { messages: [] as readonly AiMessage[], turns: prior?.turns ?? 0 };
|
|
234
|
+
const loop = async (): Promise<InferOutput<TOutput>> => {
|
|
309
235
|
// The actor is read ONCE, from the context the request established, and is the only
|
|
310
236
|
// identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
|
|
311
237
|
// name the identity it acts as, and a loop that let it would be an escalation primitive.
|
|
312
238
|
const { actor } = args.ctx;
|
|
313
|
-
let messages: readonly AiMessage[] =
|
|
239
|
+
let messages: readonly AiMessage[] =
|
|
240
|
+
prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
|
|
314
241
|
let calls = 0;
|
|
315
242
|
let issues: string | undefined;
|
|
243
|
+
// The issues of the answer the turn in flight is repairing — `onTurn`'s `repairOf`.
|
|
244
|
+
let rejected: readonly string[] | undefined;
|
|
316
245
|
|
|
317
|
-
|
|
246
|
+
// `maxTurns` counts the run AND its continuations: a handle that used them all takes none.
|
|
247
|
+
for (let turn = at.turns + 1; turn <= maxTurns; turn += 1) {
|
|
248
|
+
at.messages = messages;
|
|
318
249
|
// The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
|
|
319
250
|
// transcript, so a loop that keeps going after a disconnect spends the rest of the run's
|
|
320
251
|
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
321
252
|
throwIfAborted(args.ctx);
|
|
322
|
-
|
|
253
|
+
deadline.check();
|
|
254
|
+
const result = await deadline
|
|
255
|
+
.guard(meter.call(gateway.generate({ ...base, messages })))
|
|
256
|
+
.catch((error: unknown) => {
|
|
257
|
+
// The caller left mid-call: the aborted socket reads as a provider failure, which is
|
|
258
|
+
// RETRYABLE. Say what happened instead.
|
|
259
|
+
throwIfAborted(args.ctx);
|
|
260
|
+
throw error;
|
|
261
|
+
});
|
|
262
|
+
at.turns = turn;
|
|
323
263
|
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
324
264
|
// The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
|
|
325
265
|
// `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
|
|
@@ -337,8 +277,22 @@ async function run<
|
|
|
337
277
|
stopReason: result.stopReason,
|
|
338
278
|
usage: result.usage,
|
|
339
279
|
cost: result.cost,
|
|
280
|
+
...(result.costExact === undefined ? {} : { costExact: result.costExact }),
|
|
281
|
+
...(rejected === undefined ? {} : { repairOf: rejected }),
|
|
282
|
+
run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
|
|
340
283
|
});
|
|
341
284
|
assertAnswerable(result, name, base);
|
|
285
|
+
// Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
|
|
286
|
+
// arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
|
|
287
|
+
// reached its first byte — so running a tool on them is a side effect the model did not
|
|
288
|
+
// ask for, and validating them accepts an empty answer for any `output` of optional keys.
|
|
289
|
+
if (isTruncated(result.stopReason)) {
|
|
290
|
+
throw new LlmTruncatedError({
|
|
291
|
+
prompt: name,
|
|
292
|
+
maxTokens: base.maxTokens,
|
|
293
|
+
subject: SUBJECT,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
342
296
|
|
|
343
297
|
if (requested.length > 0) {
|
|
344
298
|
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
@@ -352,10 +306,20 @@ async function run<
|
|
|
352
306
|
// result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
|
|
353
307
|
// first cannot be paired with a slow tool's call.
|
|
354
308
|
throwIfAborted(args.ctx);
|
|
355
|
-
|
|
356
|
-
|
|
309
|
+
deadline.check();
|
|
310
|
+
const results: readonly LlmToolResult[] = await deadline.guard(
|
|
311
|
+
// Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
|
|
312
|
+
deadline.scope(() =>
|
|
313
|
+
Promise.all(
|
|
314
|
+
requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
|
|
315
|
+
),
|
|
316
|
+
),
|
|
357
317
|
);
|
|
358
318
|
calls += requested.length;
|
|
319
|
+
rejected = undefined;
|
|
320
|
+
// Before the next turn is bought: the same calls with the same results cannot lead
|
|
321
|
+
// anywhere a further turn would not also lead.
|
|
322
|
+
repeated(requested, results);
|
|
359
323
|
messages = [
|
|
360
324
|
...messages,
|
|
361
325
|
assistantTurn(result),
|
|
@@ -370,79 +334,55 @@ async function run<
|
|
|
370
334
|
}
|
|
371
335
|
|
|
372
336
|
const parsed = await validateAsync(def.output, respond.read(result));
|
|
373
|
-
if (parsed.issues === undefined)
|
|
337
|
+
if (parsed.issues === undefined) {
|
|
338
|
+
// Kept only for `.run()` / `.continue()`: a plain call leaves nothing behind.
|
|
339
|
+
if (session !== undefined) {
|
|
340
|
+
const usage = runTotal(result.model, prior?.usage, meter.total(), meter.below());
|
|
341
|
+
session.finished = {
|
|
342
|
+
transcript: sealTranscript(binding, {
|
|
343
|
+
messages: [...messages, assistantTurn(result)],
|
|
344
|
+
turns: turn,
|
|
345
|
+
usage: usage.usage,
|
|
346
|
+
}),
|
|
347
|
+
turns: turn,
|
|
348
|
+
usage,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return parsed.value;
|
|
352
|
+
}
|
|
374
353
|
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
375
354
|
// turns left by construction — and unlike a tool result, the correction is the message.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
|
|
379
|
-
}
|
|
355
|
+
rejected = formatIssues(parsed.issues);
|
|
356
|
+
issues = rejected.join('; ');
|
|
380
357
|
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
|
381
358
|
}
|
|
382
359
|
|
|
360
|
+
at.messages = messages;
|
|
383
361
|
// Two different exhaustions, so two different causes: a loop that kept calling tools and
|
|
384
362
|
// never answered is not the same event as one that answered the wrong shape every time.
|
|
385
363
|
if (issues !== undefined) {
|
|
386
|
-
throw new LlmOutputInvalidError({
|
|
364
|
+
throw new LlmOutputInvalidError({
|
|
365
|
+
prompt: name,
|
|
366
|
+
attempts: maxTurns,
|
|
367
|
+
issues,
|
|
368
|
+
...(rejected === undefined ? {} : { list: rejected }),
|
|
369
|
+
});
|
|
387
370
|
}
|
|
388
371
|
throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
V extends PromptVars,
|
|
405
|
-
>(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
|
|
406
|
-
span.addEvent('agent.turn', {
|
|
407
|
-
'agent.turn': event.turn,
|
|
408
|
-
'agent.turn.tool_calls': event.toolCalls.length,
|
|
409
|
-
'llm.stop': event.stopReason,
|
|
410
|
-
'llm.cost.minor': event.cost.minor,
|
|
411
|
-
});
|
|
412
|
-
await def.onTurn?.(event);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
|
|
416
|
-
function assertAnswerable(result: GenerateResult, name: string, request: GenerateRequest): void {
|
|
417
|
-
if (result.stopReason !== 'refusal') return;
|
|
418
|
-
throw new LlmRefusedError({
|
|
419
|
-
prompt: name,
|
|
420
|
-
model: result.model,
|
|
421
|
-
alternative: moreCapableThan(result.model, request),
|
|
422
|
-
category: result.stopDetails?.category,
|
|
423
|
-
explanation: result.stopDetails?.explanation,
|
|
372
|
+
};
|
|
373
|
+
return withBudget(ledger, () =>
|
|
374
|
+
meter.within(loop).catch((error: unknown) => {
|
|
375
|
+
const failed = meter.fail(error);
|
|
376
|
+
// A failed `.run()` / `.continue()` leaves a handle too: the transcript up to the request
|
|
377
|
+
// the failed turn was answering, with `turns` and `usage` counting what this call was
|
|
378
|
+
// billed. Without it a retry resumes from the OLD handle, which never heard of that spend
|
|
379
|
+
// — six truncated continuations billed six times against a ceiling that saw none of them.
|
|
380
|
+
if (session !== undefined && at.messages.length > 0) {
|
|
381
|
+
const { usage } = runTotal(model, prior?.usage, meter.billed());
|
|
382
|
+
withTranscript(failed, sealTranscript(binding, { ...at, usage }));
|
|
383
|
+
}
|
|
384
|
+
throw failed;
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
424
387
|
});
|
|
425
388
|
}
|
|
426
|
-
|
|
427
|
-
function limitsOf<
|
|
428
|
-
TInput extends StandardSchemaV1,
|
|
429
|
-
TOutput extends StandardSchemaV1,
|
|
430
|
-
V extends PromptVars,
|
|
431
|
-
>(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
|
|
432
|
-
const budget = def.budget;
|
|
433
|
-
return {
|
|
434
|
-
// Screened under the names the DECLARATION uses, not the ledger's: `tokensPerRun` arrives at
|
|
435
|
-
// `BudgetLimits` as `request`, and a fix line naming a key the app never wrote is not a fix.
|
|
436
|
-
...(budget?.tokensIn === undefined
|
|
437
|
-
? {}
|
|
438
|
-
: { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
|
|
439
|
-
...(budget?.costPerCall === undefined
|
|
440
|
-
? {}
|
|
441
|
-
: { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
|
|
442
|
-
// The ledger's `request` scope accumulates across every call made under one ledger, which for
|
|
443
|
-
// a run under `withBudget` is exactly "the whole run".
|
|
444
|
-
...(budget?.tokensPerRun === undefined
|
|
445
|
-
? {}
|
|
446
|
-
: { request: finiteCount(SUBJECT, 'budget.tokensPerRun', budget.tokensPerRun) }),
|
|
447
|
-
};
|
|
448
|
-
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The two failures of an answer that ARRIVED and cannot be used: cut off at the ceiling, or a tool
|
|
2
|
+
// call whose arguments do not parse. Apart from ./errors only because one file has one job and
|
|
3
|
+
// that catalogue is at its line ceiling — the split `hive-errors.ts` and `agent-errors.ts` made.
|
|
4
|
+
// The codes, their titles and the single `registerErrorCodes` call stay in ./errors.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { Billed } from './billed';
|
|
8
|
+
import { BILLED_META } from './billed';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The answer was cut off at the enforced ceiling. Thrown BEFORE the answer is read, and instead of
|
|
12
|
+
* a repair turn: a cut-off `respond` call arrives with no arguments, which both wires read as `{}`
|
|
13
|
+
* and an `output` of optional keys accepts — and the ceiling does not move between attempts, so a
|
|
14
|
+
* second answer truncates at exactly the same place and only spends money.
|
|
15
|
+
*/
|
|
16
|
+
export class LlmTruncatedError extends UltimateError {
|
|
17
|
+
constructor(input: {
|
|
18
|
+
prompt: string;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
/** The factory whose declaration carries the ceiling. */
|
|
21
|
+
subject?: 'llm' | 'agent';
|
|
22
|
+
}) {
|
|
23
|
+
super({
|
|
24
|
+
code: 'X_LLM_TRUNCATED',
|
|
25
|
+
cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
|
|
26
|
+
fix: `maxTokens: ${input.maxTokens * 2} # on the ${input.subject ?? 'llm'}() declaration — or drop fields from its output schema`,
|
|
27
|
+
meta: { prompt: input.prompt, maxTokens: input.maxTokens },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The model finished its turn and the arguments of a tool call it made are not JSON. Not a
|
|
34
|
+
* transport failure: the provider answered, the answer was billed, and asking the next provider
|
|
35
|
+
* (or the same one three more times) buys the same request again. So it is its own code, the
|
|
36
|
+
* gateway does not retry it, and it carries the bill.
|
|
37
|
+
*
|
|
38
|
+
* The cause never quotes the arguments. They are the model's text, and for `respond` they are the
|
|
39
|
+
* answer — which is where whatever the prompt loaded ends up.
|
|
40
|
+
*/
|
|
41
|
+
export class LlmToolArgumentsError extends UltimateError {
|
|
42
|
+
constructor(input: {
|
|
43
|
+
provider: string;
|
|
44
|
+
model: string;
|
|
45
|
+
tool: string;
|
|
46
|
+
chars: number;
|
|
47
|
+
billed: Billed;
|
|
48
|
+
/** Fragments with no index and no id: two calls merged, not one call broken. */
|
|
49
|
+
unkeyed?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_LLM_TOOL_ARGUMENTS_INVALID',
|
|
53
|
+
cause:
|
|
54
|
+
input.unkeyed === true
|
|
55
|
+
? `provider "${input.provider}" streamed tool-call fragments for model "${input.model}" with no index and no id, and more than one of them named a tool — the calls cannot be told apart, so none was run`
|
|
56
|
+
: `model "${input.model}" on provider "${input.provider}" finished its turn calling tool "${input.tool}" with ${input.chars} characters of arguments that are not JSON`,
|
|
57
|
+
fix: `maxTokens: 8192 # on the declaration, if the arguments were long — and shorten or split the string fields in the input schema of "${input.tool}", the place a model breaks JSON. Terminal for a job: to run the same request once more, catch this code and re-enqueue it deliberately`,
|
|
58
|
+
meta: {
|
|
59
|
+
provider: input.provider,
|
|
60
|
+
model: input.model,
|
|
61
|
+
tool: input.tool,
|
|
62
|
+
[BILLED_META]: input.billed,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A chat-completions endpoint finished a turn with a reason this build does not know, and the turn
|
|
70
|
+
* is not whole — a tool call whose arguments do not parse — or the reason was `error`. Not
|
|
71
|
+
* truncation (nothing says the ceiling was reached) and not a transport failure (the endpoint
|
|
72
|
+
* answered, and billed): its own code, carrying the reason and the bill.
|
|
73
|
+
*/
|
|
74
|
+
export class AiProviderUnexpectedError extends UltimateError {
|
|
75
|
+
constructor(input: { provider: string; model: string; reason: string; billed: Billed }) {
|
|
76
|
+
super({
|
|
77
|
+
code: 'X_AI_PROVIDER_UNEXPECTED',
|
|
78
|
+
cause: `provider "${input.provider}" finished a turn of model "${input.model}" with the finish reason ${JSON.stringify(input.reason.slice(0, 64))}, which this build does not know, and the answer is not complete`,
|
|
79
|
+
fix: "x doctor --json # then check what the endpoint means by the reason in cause: a truncation needs a higher maxTokens on the declaration, anything else is the endpoint's to explain",
|
|
80
|
+
meta: {
|
|
81
|
+
provider: input.provider,
|
|
82
|
+
model: input.model,
|
|
83
|
+
reason: input.reason.slice(0, 64),
|
|
84
|
+
[BILLED_META]: input.billed,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|