@ultimat3/ai 27.9.0 → 27.10.1

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.
@@ -0,0 +1,54 @@
1
+ // Single responsibility: what the loop does with ONE completed model turn before it acts on it —
2
+ // report it, and refuse the two stops that are not an answer. Split from ./agent at its line
3
+ // ceiling.
4
+
5
+ import type { Span } from '@ultimat3/core';
6
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
7
+ import type { AgentDef, AgentTurn } from './agent-def';
8
+ import { LlmRefusedError } from './errors';
9
+ import { moreCapableThan } from './models';
10
+ import type { PromptVars } from './prompt';
11
+ import type { GenerateRequest, GenerateResult } from './provider';
12
+
13
+ /**
14
+ * One turn, reported twice: onto the span every run already opens, and to the declaration's own
15
+ * hook. The span event is free — nothing to install, and a trace is where a stalled run is
16
+ * actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
17
+ *
18
+ * Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
19
+ * path, and an observer that quietly stopped working reads exactly like one that is fine.
20
+ */
21
+ export async function observeTurn<
22
+ TInput extends StandardSchemaV1,
23
+ TOutput extends StandardSchemaV1,
24
+ V extends PromptVars,
25
+ >(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
26
+ span.addEvent('agent.turn', {
27
+ 'agent.turn': event.turn,
28
+ 'agent.turn.tool_calls': event.toolCalls.length,
29
+ 'llm.stop': event.stopReason,
30
+ 'llm.cost.minor': event.cost.minor,
31
+ 'agent.turn.repair': event.repairOf !== undefined,
32
+ // What the provider's prompt cache did for this turn — the two numbers that say whether the
33
+ // stable prefix is really stable.
34
+ 'llm.cache.read_tokens': event.usage.cacheReadTokens,
35
+ 'llm.cache.write_tokens': event.usage.cacheWriteTokens,
36
+ });
37
+ await def.onTurn?.(event);
38
+ }
39
+
40
+ /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
41
+ export function assertAnswerable(
42
+ result: GenerateResult,
43
+ name: string,
44
+ request: GenerateRequest,
45
+ ): void {
46
+ if (result.stopReason !== 'refusal') return;
47
+ throw new LlmRefusedError({
48
+ prompt: name,
49
+ model: result.model,
50
+ alternative: moreCapableThan(result.model, request),
51
+ category: result.stopDetails?.category,
52
+ explanation: result.stopDetails?.explanation,
53
+ });
54
+ }
@@ -1,27 +1,63 @@
1
- // Single responsibility: a run's running token and money total, across its turns.
1
+ // Single responsibility: a run's running token and money total, across its turns and its
2
+ // continuations.
2
3
 
3
4
  import type { Money } from '@ultimat3/money';
5
+ import type { Billed } from './billed';
6
+ import { addMoney, costOf, exactCostOf, roundUpCost } from './cost';
7
+ import type { ModelId } from './models';
4
8
  import type { GenerateResult, TokenUsage } from './provider';
5
- import { costOf } from './provider';
6
9
 
7
10
  /** The run so far, this turn included. */
8
11
  export interface AgentRunUsage {
9
12
  readonly usage: TokenUsage;
13
+ /** In the currency's minor units, rounded up ONCE over the whole run — never a sum of turns. */
10
14
  readonly cost: Money;
15
+ /** The same figure exactly, as `Money` at a finer `scale` (`exactCostOf`). */
16
+ readonly costExact: Money;
17
+ }
18
+
19
+ const ZERO: TokenUsage = {
20
+ inputTokens: 0,
21
+ outputTokens: 0,
22
+ cacheReadTokens: 0,
23
+ cacheWriteTokens: 0,
24
+ };
25
+
26
+ const plus = (a: TokenUsage, b: TokenUsage): TokenUsage => ({
27
+ inputTokens: a.inputTokens + b.inputTokens,
28
+ outputTokens: a.outputTokens + b.outputTokens,
29
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
30
+ cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
31
+ });
32
+
33
+ /** `usage` on `model`, priced once: each turn's own cost is rounded up, and ten round-ups is not the price of the run. */
34
+ const priced = (model: ModelId, usage: TokenUsage): AgentRunUsage => ({
35
+ usage,
36
+ cost: costOf(model, usage),
37
+ costExact: exactCostOf(model, usage),
38
+ });
39
+
40
+ /** The run's total after one more turn, re-priced from the summed usage. */
41
+ export function addUsage(total: AgentRunUsage | undefined, result: GenerateResult): AgentRunUsage {
42
+ return priced(result.model, plus(total?.usage ?? ZERO, result.usage));
11
43
  }
12
44
 
13
45
  /**
14
- * The run's total after one more turn. The cost is re-priced from the summed usage rather than
15
- * summed per turn: each turn's cost is rounded UP to a minor unit, and ten round-ups is not the
16
- * price of the run.
46
+ * A run's total across its continuations: what the handle already held plus what this call used,
47
+ * priced once from the sum.
17
48
  */
18
- export function addUsage(total: AgentRunUsage | undefined, result: GenerateResult): AgentRunUsage {
19
- const before = total?.usage;
20
- const usage: TokenUsage = {
21
- inputTokens: (before?.inputTokens ?? 0) + result.usage.inputTokens,
22
- outputTokens: (before?.outputTokens ?? 0) + result.usage.outputTokens,
23
- cacheReadTokens: (before?.cacheReadTokens ?? 0) + result.usage.cacheReadTokens,
24
- cacheWriteTokens: (before?.cacheWriteTokens ?? 0) + result.usage.cacheWriteTokens,
49
+ export function runTotal(
50
+ model: ModelId,
51
+ prior: TokenUsage | undefined,
52
+ own: { readonly usage: TokenUsage } | undefined,
53
+ /** What runs underneath were billed (`RunMeter.below`): another model's price, so ADDED, never re-priced. */
54
+ below?: Billed,
55
+ ): AgentRunUsage {
56
+ const base = priced(model, plus(prior ?? ZERO, own?.usage ?? ZERO));
57
+ if (below === undefined || below.cost.currency !== base.cost.currency) return base;
58
+ return {
59
+ usage: plus(base.usage, below.usage),
60
+ cost: addMoney(base.cost, roundUpCost(below.cost)),
61
+ costExact: addMoney(base.costExact, below.cost),
25
62
  };
26
- return { usage, cost: costOf(result.model, usage) };
27
63
  }
package/src/agent.ts CHANGED
@@ -22,61 +22,47 @@
22
22
 
23
23
  import type { Action } from '@ultimat3/action';
24
24
  import { action } from '@ultimat3/action';
25
- import type { Ctx, Span } from '@ultimat3/core';
26
- import { finiteCount, isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
25
+ import type { Ctx } from '@ultimat3/core';
26
+ import { throwIfAborted, withSpan } from '@ultimat3/core';
27
27
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
28
28
  import { formatIssues, validateAsync } from '@ultimat3/schema';
29
- import type { AgentDef, AgentTurn } from './agent-def';
30
- import type { AgentFact } from './agent-facts';
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';
32
- import { DEFAULT_MAX_REPEATED_TOOL_CALLS, runDeadline, toolLoopGuard } from './agent-guards';
43
+ import { runDeadline, toolLoopGuard } from './agent-guards';
33
44
  import { sameSystem, systemOf } from './agent-system';
34
45
  import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
35
- import type { AgentRunUsage } from './agent-usage';
36
- import { addUsage } from './agent-usage';
37
- import type { BudgetLimits } from './budget';
38
- import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
39
- import {
40
- AgentMaxTurnsError,
41
- AgentToolUnexposedError,
42
- AiRequestInvalidError,
43
- LlmOutputInvalidError,
44
- LlmRefusedError,
45
- LlmTruncatedError,
46
- } from './errors';
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';
47
51
  import { answerAttributes } from './llm';
48
- import { describedModel, type ModelSource, modelForCall } from './model-resolve';
49
- import { moreCapableThan } from './models';
52
+ import { modelForCall } from './model-resolve';
50
53
  import type { PromptVars } from './prompt';
51
54
  import { promptCacheHint } from './prompt-cache';
52
- import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
55
+ import type { AiMessage, GenerateRequest } from './provider';
53
56
  import { isTruncated } from './provider';
54
57
  import { assertNoSecrets } from './redaction';
55
58
  import type { Respond } from './respond';
56
59
  import { RESPOND, respondFor } from './respond';
60
+ import { runMeter } from './run-meter';
57
61
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
62
+ import type { Sampling } from './sampling';
63
+ import { ignoredSamplingAttribute, screenSampling } from './sampling';
58
64
  import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
59
- import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
60
-
61
- /**
62
- * Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
63
- * is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
64
- * grows quadratically in turns rather than linearly.
65
- */
66
- const DEFAULT_MAX_TURNS = 8;
67
-
68
- /** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
69
- const DEFAULT_MAX_TOKENS = 4_096;
70
-
71
- /**
72
- * Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
73
- * spends the whole context window on turn two and every later turn re-sends it — the transcript
74
- * is the request, so an untruncated result is billed once per remaining turn.
75
- */
76
- const DEFAULT_TOOL_RESULT_CHARS = 4_000;
77
-
78
- /** Named in every bound refusal, so the fix names the key the declaration carries. */
79
- const SUBJECT = 'agent';
65
+ import { asProjectableAction, isOfferedTool, runLlmToolCall, toLlmTools, toolLabel } from './tools';
80
66
 
81
67
  export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
82
68
 
@@ -84,49 +70,23 @@ export function agent<
84
70
  TInput extends StandardSchemaV1,
85
71
  TOutput extends StandardSchemaV1,
86
72
  V extends PromptVars,
87
- >(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
73
+ >(def: AgentDef<TInput, TOutput, V>): AgentAction<TInput, TOutput> {
88
74
  const respond = respondFor(def.output);
89
75
  // At declaration, because the tools are values by then and a run that discovers this at the
90
76
  // first request has already been declared, registered and projected as if it worked. Asked of
91
77
  // the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
92
78
  // `action()` beside this one in the same module has none until `registerActions` runs at boot.
93
- const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
79
+ const unexposed = def.tools.filter((tool) => !isOfferedTool(tool));
94
80
  if (unexposed.length > 0) {
95
81
  throw new AgentToolUnexposedError({
96
82
  agent: def.prompt.ref,
97
83
  tools: unexposed.map(toolLabel),
98
84
  });
99
85
  }
100
- // The three loop bounds, screened at DECLARATION for the same reason the tool check above is:
101
- // the values are here, and an `agent()` runs at module scope, so a bound that is not one fails
102
- // the boot instead of the ninetieth second of a run. None of them is checked by what it lands
103
- // on — `turn <= NaN` is false on the FIRST comparison, so the loop takes zero turns and reports
104
- // `X_AGENT_MAX_TURNS` about a model that was never called; `slice(0, NaN)` is `''`, so every
105
- // tool result reaches the model empty while the run keeps paying for turns; and `maxTokens`
106
- // becomes the pre-flight estimate, where a `NaN` disables the ledger for the whole process.
107
- finiteCount(SUBJECT, 'maxTurns', def.maxTurns ?? DEFAULT_MAX_TURNS, 1);
108
- finiteCount(
109
- SUBJECT,
110
- 'maxToolResultChars',
111
- def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
112
- 1,
113
- );
114
- finiteCount(SUBJECT, 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
115
- const repeats = finiteCount(
116
- SUBJECT,
117
- 'maxRepeatedToolCalls',
118
- def.maxRepeatedToolCalls ?? DEFAULT_MAX_REPEATED_TOOL_CALLS,
119
- 1,
120
- );
121
- if (repeats < 2) {
122
- // One "repeat" is the first tool turn of every run: the agent could never use a tool.
123
- throw new AiRequestInvalidError({
124
- detail: `agent maxRepeatedToolCalls is ${repeats}, which would stop every run at its first tool turn`,
125
- fix: 'maxRepeatedToolCalls: 3 # on the agent — at least 2; it counts consecutive identical turns, the first included',
126
- });
127
- }
128
- // `AbortSignal.timeout(NaN)` throws a RangeError mid-run; `0` aborts before turn one.
129
- if (def.deadlineMs !== undefined) finiteCount(SUBJECT, 'deadlineMs', def.deadlineMs, 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);
130
90
  // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
131
91
  // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
132
92
  // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
@@ -140,48 +100,27 @@ export function agent<
140
100
  output: def.output,
141
101
  policy: def.policy,
142
102
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
143
- handle: (args) => run(def, respond, adapt(), args),
103
+ handle: (args) => run(def, respond, adapt(), { ...bounds, sampling, owner }, args),
144
104
  });
145
105
  // A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
146
106
  // line runs at module scope.
147
107
  registerAgentFact(built, () => factsOf(def));
148
- return built;
108
+ return continuable(built, owner);
149
109
  }
150
110
 
151
- // `null`, not `undefined`, for a row naming no model yet: the row is a published fact, and an
152
- // absent key reads as a reader that forgot to ask.
153
- const modelFact = (described: {
154
- readonly model: string | undefined;
155
- readonly from: ModelSource | undefined;
156
- }): Pick<AgentFact, 'model' | 'modelFrom'> => ({
157
- model: described.model ?? null,
158
- modelFrom: described.from ?? null,
159
- });
160
-
161
- function factsOf<
162
- TInput extends StandardSchemaV1,
163
- TOutput extends StandardSchemaV1,
164
- V extends PromptVars,
165
- >(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
166
- const budget = def.budget;
167
- return {
168
- prompt: def.prompt.ref,
169
- promptId: def.prompt.id,
170
- promptHash: def.prompt.hash,
171
- // The run's own precedence, so the row names the model a call would use NOW: read when the
172
- // facts are, at describe time, like every name in the row. `modelFrom` says which place
173
- // answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
174
- ...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
175
- maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
176
- maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
177
- tools: [...def.tools.map(toolLabel)].sort(),
178
- budget: {
179
- tokensIn: budget?.tokensIn ?? null,
180
- tokensPerRun: budget?.tokensPerRun ?? null,
181
- costPerCall: budget?.costPerCall ?? null,
182
- },
183
- mcp: isMcpExposed(def.mcp),
184
- };
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
+ });
185
124
  }
186
125
 
187
126
  /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
@@ -203,29 +142,50 @@ async function run<
203
142
  def: AgentDef<TInput, TOutput, V>,
204
143
  respond: Respond,
205
144
  adapted: Adapted,
145
+ bounds: AgentBounds & { readonly sampling: Sampling; readonly owner: object },
206
146
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
207
147
  ): Promise<InferOutput<TOutput>> {
208
148
  const { prompt } = def;
209
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
+ }
210
157
  // Screened first: a bad declaration is refused whether or not a gateway exists.
211
- const limits = limitsOf(def);
158
+ limitsOf(def);
212
159
  // The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
213
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.
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);
214
170
  // Armed before anything is loaded: `vars()` is part of the run a deadline bounds.
215
171
  const deadline = runDeadline(name, def.deadlineMs, args.ctx.signal);
216
- const vars = await deadline.guard(Promise.resolve(def.vars(args)));
217
- assertNoSecrets(name, vars);
218
172
  const redact = aiRedactor();
219
- const rawPrompt = prompt.render(vars);
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
+ }
220
181
  const rendered = redact(rawPrompt);
221
182
  const extra =
222
183
  def.system === undefined ? {} : await deadline.guard(Promise.resolve(def.system(args)));
223
184
  const rawSystem = systemOf(prompt.system, extra, (text) => text);
224
185
  const system = systemOf(prompt.system, extra, redact);
225
186
  const promptCache = promptCacheHint(def.promptCache, 'auto', args);
226
- const repeated = toolLoopGuard(name, def.maxRepeatedToolCalls ?? DEFAULT_MAX_REPEATED_TOOL_CALLS);
227
- const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
228
- const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
187
+ const repeated = toolLoopGuard(name, bounds.maxRepeatedToolCalls);
188
+ const { maxTurns, maxToolResultChars: chars } = bounds;
229
189
 
230
190
  return withSpan('ai.agent', async (span) => {
231
191
  span.setAttributes({
@@ -237,6 +197,14 @@ async function run<
237
197
  'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
238
198
  'llm.prompt_cache': promptCache !== undefined,
239
199
  });
200
+ // Read when the run ends, not here: the gateway may be what registers the model's row.
201
+ const reportSampling = (): void => {
202
+ span.setAttributes(
203
+ ignoredSamplingAttribute(model, bounds.sampling, () =>
204
+ installedGateway()?.samplingWires?.(model),
205
+ ),
206
+ );
207
+ };
240
208
 
241
209
  // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
242
210
  const gateway = aiGateway(name);
@@ -249,9 +217,10 @@ async function run<
249
217
  ...(system === undefined ? {} : { system }),
250
218
  ...(promptCache === undefined ? {} : { promptCache }),
251
219
  messages: [],
252
- maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
220
+ maxTokens: bounds.maxTokens,
253
221
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
254
222
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
223
+ ...bounds.sampling,
255
224
  tools: [...adapted.offered, respond.tool],
256
225
  // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
257
226
  // not just the top of the next turn: a provider call already in flight is the expensive one.
@@ -259,31 +228,41 @@ async function run<
259
228
  signal: deadline.signal,
260
229
  };
261
230
 
262
- return withBudget(ledger, async () => {
231
+ // Every provider call below goes through the meter, so whatever ends the run — a ceiling, a
232
+ // deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
233
+ const meter = runMeter();
234
+ const disclosure = { discloseErrors: def.discloseToolErrors !== false };
235
+ // Where a failure would resume from: the request in flight, and the turns answered so far.
236
+ const at = { messages: [] as readonly AiMessage[], turns: prior?.turns ?? 0 };
237
+ const loop = async (): Promise<InferOutput<TOutput>> => {
263
238
  // The actor is read ONCE, from the context the request established, and is the only
264
239
  // identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
265
240
  // name the identity it acts as, and a loop that let it would be an escalation primitive.
266
241
  const { actor } = args.ctx;
267
- let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
242
+ let messages: readonly AiMessage[] =
243
+ prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
268
244
  let calls = 0;
269
245
  let issues: string | undefined;
270
- let total: AgentRunUsage | undefined;
246
+ // The issues of the answer the turn in flight is repairing — `onTurn`'s `repairOf`.
247
+ let rejected: readonly string[] | undefined;
271
248
 
272
- for (let turn = 1; turn <= maxTurns; turn += 1) {
249
+ // `maxTurns` counts the run AND its continuations: a handle that used them all takes none.
250
+ for (let turn = at.turns + 1; turn <= maxTurns; turn += 1) {
251
+ at.messages = messages;
273
252
  // The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
274
253
  // transcript, so a loop that keeps going after a disconnect spends the rest of the run's
275
254
  // budget, runs every remaining tool's side effects, and discards the answer.
276
255
  throwIfAborted(args.ctx);
277
256
  deadline.check();
278
257
  const result = await deadline
279
- .guard(gateway.generate({ ...base, messages }))
258
+ .guard(meter.call(gateway.generate({ ...base, messages })))
280
259
  .catch((error: unknown) => {
281
260
  // The caller left mid-call: the aborted socket reads as a provider failure, which is
282
261
  // RETRYABLE. Say what happened instead.
283
262
  throwIfAborted(args.ctx);
284
263
  throw error;
285
264
  });
286
- total = addUsage(total, result);
265
+ at.turns = turn;
287
266
  const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
288
267
  // The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
289
268
  // `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
@@ -301,9 +280,22 @@ async function run<
301
280
  stopReason: result.stopReason,
302
281
  usage: result.usage,
303
282
  cost: result.cost,
304
- run: total,
283
+ ...(result.costExact === undefined ? {} : { costExact: result.costExact }),
284
+ ...(rejected === undefined ? {} : { repairOf: rejected }),
285
+ run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
305
286
  });
306
287
  assertAnswerable(result, name, base);
288
+ // Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
289
+ // arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
290
+ // reached its first byte — so running a tool on them is a side effect the model did not
291
+ // ask for, and validating them accepts an empty answer for any `output` of optional keys.
292
+ if (isTruncated(result.stopReason)) {
293
+ throw new LlmTruncatedError({
294
+ prompt: name,
295
+ maxTokens: base.maxTokens,
296
+ subject: SUBJECT,
297
+ });
298
+ }
307
299
 
308
300
  if (requested.length > 0) {
309
301
  // Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
@@ -321,10 +313,13 @@ async function run<
321
313
  const results: readonly LlmToolResult[] = await deadline.guard(
322
314
  // Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
323
315
  deadline.scope(() =>
324
- Promise.all(requested.map((call) => runLlmToolCall(adapted.tools, call, actor))),
316
+ Promise.all(
317
+ requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
318
+ ),
325
319
  ),
326
320
  );
327
321
  calls += requested.length;
322
+ rejected = undefined;
328
323
  // Before the next turn is bought: the same calls with the same results cannot lead
329
324
  // anywhere a further turn would not also lead.
330
325
  repeated(requested, results);
@@ -342,83 +337,58 @@ async function run<
342
337
  }
343
338
 
344
339
  const parsed = await validateAsync(def.output, respond.read(result));
345
- if (parsed.issues === undefined) return parsed.value;
340
+ if (parsed.issues === undefined) {
341
+ // Kept only for `.run()` / `.continue()`: a plain call leaves nothing behind.
342
+ if (session !== undefined) {
343
+ const usage = runTotal(result.model, prior?.usage, meter.total(), meter.below());
344
+ session.finished = {
345
+ transcript: sealTranscript(binding, {
346
+ messages: [...messages, assistantTurn(result)],
347
+ turns: turn,
348
+ usage: usage.usage,
349
+ }),
350
+ turns: turn,
351
+ usage,
352
+ };
353
+ }
354
+ return parsed.value;
355
+ }
346
356
  // A wrong shape gets another turn like any other, because unlike `llm()` this loop has
347
357
  // turns left by construction — and unlike a tool result, the correction is the message.
348
- issues = formatIssues(parsed.issues).join('; ');
349
- if (isTruncated(result.stopReason)) {
350
- throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
351
- }
358
+ rejected = formatIssues(parsed.issues);
359
+ issues = rejected.join('; ');
352
360
  messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
353
361
  }
354
362
 
363
+ at.messages = messages;
355
364
  // Two different exhaustions, so two different causes: a loop that kept calling tools and
356
365
  // never answered is not the same event as one that answered the wrong shape every time.
357
366
  if (issues !== undefined) {
358
- throw new LlmOutputInvalidError({ prompt: name, attempts: maxTurns, issues });
367
+ throw new LlmOutputInvalidError({
368
+ prompt: name,
369
+ attempts: maxTurns,
370
+ issues,
371
+ ...(rejected === undefined ? {} : { list: rejected }),
372
+ });
359
373
  }
360
374
  throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
361
- });
362
- });
363
- }
364
-
365
- /**
366
- * One turn, reported twice: onto the span every run already opens, and to the declaration's own
367
- * hook. The span event is free — nothing to install, and a trace is where a stalled run is
368
- * actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
369
- *
370
- * Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
371
- * path, and an observer that quietly stopped working reads exactly like one that is fine.
372
- */
373
- async function observeTurn<
374
- TInput extends StandardSchemaV1,
375
- TOutput extends StandardSchemaV1,
376
- V extends PromptVars,
377
- >(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
378
- span.addEvent('agent.turn', {
379
- 'agent.turn': event.turn,
380
- 'agent.turn.tool_calls': event.toolCalls.length,
381
- 'llm.stop': event.stopReason,
382
- 'llm.cost.minor': event.cost.minor,
383
- // What the provider's prompt cache did for this turn — the two numbers that say whether the
384
- // stable prefix is really stable.
385
- 'llm.cache.read_tokens': event.usage.cacheReadTokens,
386
- 'llm.cache.write_tokens': event.usage.cacheWriteTokens,
387
- });
388
- await def.onTurn?.(event);
389
- }
390
-
391
- /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
392
- function assertAnswerable(result: GenerateResult, name: string, request: GenerateRequest): void {
393
- if (result.stopReason !== 'refusal') return;
394
- throw new LlmRefusedError({
395
- prompt: name,
396
- model: result.model,
397
- alternative: moreCapableThan(result.model, request),
398
- category: result.stopDetails?.category,
399
- explanation: result.stopDetails?.explanation,
375
+ };
376
+ return withBudget(ledger, () =>
377
+ meter
378
+ .within(loop)
379
+ .catch((error: unknown) => {
380
+ const failed = meter.fail(error);
381
+ // A failed `.run()` / `.continue()` leaves a handle too: the transcript up to the request
382
+ // the failed turn was answering, with `turns` and `usage` counting what this call was
383
+ // billed. Without it a retry resumes from the OLD handle, which never heard of that spend
384
+ // — six truncated continuations billed six times against a ceiling that saw none of them.
385
+ if (session !== undefined && at.messages.length > 0) {
386
+ const { usage } = runTotal(model, prior?.usage, meter.billed());
387
+ withTranscript(failed, sealTranscript(binding, { ...at, usage }));
388
+ }
389
+ throw failed;
390
+ })
391
+ .finally(reportSampling),
392
+ );
400
393
  });
401
394
  }
402
-
403
- function limitsOf<
404
- TInput extends StandardSchemaV1,
405
- TOutput extends StandardSchemaV1,
406
- V extends PromptVars,
407
- >(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
408
- const budget = def.budget;
409
- return {
410
- // Screened under the names the DECLARATION uses, not the ledger's: `tokensPerRun` arrives at
411
- // `BudgetLimits` as `request`, and a fix line naming a key the app never wrote is not a fix.
412
- ...(budget?.tokensIn === undefined
413
- ? {}
414
- : { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
415
- ...(budget?.costPerCall === undefined
416
- ? {}
417
- : { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
418
- // The ledger's `request` scope accumulates across every call made under one ledger, which for
419
- // a run under `withBudget` is exactly "the whole run".
420
- ...(budget?.tokensPerRun === undefined
421
- ? {}
422
- : { request: finiteCount(SUBJECT, 'budget.tokensPerRun', budget.tokensPerRun) }),
423
- };
424
- }