@ultimat3/ai 27.9.0 → 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 +104 -108
- package/README.md +21 -5
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +28 -5
- package/src/agent-errors.ts +20 -1
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +49 -13
- package/src/agent.ts +157 -193
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +37 -10
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +8 -0
- package/src/errors.ts +22 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +53 -7
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +29 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +86 -74
- package/src/models.ts +45 -1
- package/src/openai-body.ts +6 -3
- package/src/openai-provider.ts +11 -2
- package/src/openai-wire.ts +168 -62
- package/src/provider.ts +32 -34
- 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
|
@@ -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
|
+
}
|
package/src/agent-usage.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
26
|
-
import {
|
|
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 {
|
|
30
|
-
import
|
|
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 {
|
|
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
|
|
36
|
-
import {
|
|
37
|
-
import
|
|
38
|
-
import { budgetKeysFor, currentBudget,
|
|
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 {
|
|
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
|
|
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 { ignoredSampling, 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>):
|
|
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) => !
|
|
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
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
227
|
-
const maxTurns
|
|
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({
|
|
@@ -236,6 +196,11 @@ async function run<
|
|
|
236
196
|
'agent.max_turns': maxTurns,
|
|
237
197
|
'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
|
|
238
198
|
'llm.prompt_cache': promptCache !== undefined,
|
|
199
|
+
'llm.sampling.ignored': ignoredSampling(
|
|
200
|
+
model,
|
|
201
|
+
bounds.sampling,
|
|
202
|
+
installedGateway()?.samplingWires?.(model),
|
|
203
|
+
).join(','),
|
|
239
204
|
});
|
|
240
205
|
|
|
241
206
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
@@ -249,9 +214,10 @@ async function run<
|
|
|
249
214
|
...(system === undefined ? {} : { system }),
|
|
250
215
|
...(promptCache === undefined ? {} : { promptCache }),
|
|
251
216
|
messages: [],
|
|
252
|
-
maxTokens:
|
|
217
|
+
maxTokens: bounds.maxTokens,
|
|
253
218
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
254
219
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
220
|
+
...bounds.sampling,
|
|
255
221
|
tools: [...adapted.offered, respond.tool],
|
|
256
222
|
// The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
|
|
257
223
|
// not just the top of the next turn: a provider call already in flight is the expensive one.
|
|
@@ -259,31 +225,41 @@ async function run<
|
|
|
259
225
|
signal: deadline.signal,
|
|
260
226
|
};
|
|
261
227
|
|
|
262
|
-
|
|
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>> => {
|
|
263
235
|
// The actor is read ONCE, from the context the request established, and is the only
|
|
264
236
|
// identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
|
|
265
237
|
// name the identity it acts as, and a loop that let it would be an escalation primitive.
|
|
266
238
|
const { actor } = args.ctx;
|
|
267
|
-
let messages: readonly AiMessage[] =
|
|
239
|
+
let messages: readonly AiMessage[] =
|
|
240
|
+
prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
|
|
268
241
|
let calls = 0;
|
|
269
242
|
let issues: string | undefined;
|
|
270
|
-
|
|
243
|
+
// The issues of the answer the turn in flight is repairing — `onTurn`'s `repairOf`.
|
|
244
|
+
let rejected: readonly string[] | undefined;
|
|
271
245
|
|
|
272
|
-
|
|
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;
|
|
273
249
|
// The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
|
|
274
250
|
// transcript, so a loop that keeps going after a disconnect spends the rest of the run's
|
|
275
251
|
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
276
252
|
throwIfAborted(args.ctx);
|
|
277
253
|
deadline.check();
|
|
278
254
|
const result = await deadline
|
|
279
|
-
.guard(gateway.generate({ ...base, messages }))
|
|
255
|
+
.guard(meter.call(gateway.generate({ ...base, messages })))
|
|
280
256
|
.catch((error: unknown) => {
|
|
281
257
|
// The caller left mid-call: the aborted socket reads as a provider failure, which is
|
|
282
258
|
// RETRYABLE. Say what happened instead.
|
|
283
259
|
throwIfAborted(args.ctx);
|
|
284
260
|
throw error;
|
|
285
261
|
});
|
|
286
|
-
|
|
262
|
+
at.turns = turn;
|
|
287
263
|
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
288
264
|
// The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
|
|
289
265
|
// `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
|
|
@@ -301,9 +277,22 @@ async function run<
|
|
|
301
277
|
stopReason: result.stopReason,
|
|
302
278
|
usage: result.usage,
|
|
303
279
|
cost: result.cost,
|
|
304
|
-
|
|
280
|
+
...(result.costExact === undefined ? {} : { costExact: result.costExact }),
|
|
281
|
+
...(rejected === undefined ? {} : { repairOf: rejected }),
|
|
282
|
+
run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
|
|
305
283
|
});
|
|
306
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
|
+
}
|
|
307
296
|
|
|
308
297
|
if (requested.length > 0) {
|
|
309
298
|
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
@@ -321,10 +310,13 @@ async function run<
|
|
|
321
310
|
const results: readonly LlmToolResult[] = await deadline.guard(
|
|
322
311
|
// Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
|
|
323
312
|
deadline.scope(() =>
|
|
324
|
-
Promise.all(
|
|
313
|
+
Promise.all(
|
|
314
|
+
requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
|
|
315
|
+
),
|
|
325
316
|
),
|
|
326
317
|
);
|
|
327
318
|
calls += requested.length;
|
|
319
|
+
rejected = undefined;
|
|
328
320
|
// Before the next turn is bought: the same calls with the same results cannot lead
|
|
329
321
|
// anywhere a further turn would not also lead.
|
|
330
322
|
repeated(requested, results);
|
|
@@ -342,83 +334,55 @@ async function run<
|
|
|
342
334
|
}
|
|
343
335
|
|
|
344
336
|
const parsed = await validateAsync(def.output, respond.read(result));
|
|
345
|
-
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
|
+
}
|
|
346
353
|
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
347
354
|
// turns left by construction — and unlike a tool result, the correction is the message.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
|
|
351
|
-
}
|
|
355
|
+
rejected = formatIssues(parsed.issues);
|
|
356
|
+
issues = rejected.join('; ');
|
|
352
357
|
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
|
353
358
|
}
|
|
354
359
|
|
|
360
|
+
at.messages = messages;
|
|
355
361
|
// Two different exhaustions, so two different causes: a loop that kept calling tools and
|
|
356
362
|
// never answered is not the same event as one that answered the wrong shape every time.
|
|
357
363
|
if (issues !== undefined) {
|
|
358
|
-
throw new LlmOutputInvalidError({
|
|
364
|
+
throw new LlmOutputInvalidError({
|
|
365
|
+
prompt: name,
|
|
366
|
+
attempts: maxTurns,
|
|
367
|
+
issues,
|
|
368
|
+
...(rejected === undefined ? {} : { list: rejected }),
|
|
369
|
+
});
|
|
359
370
|
}
|
|
360
371
|
throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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,
|
|
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
|
+
);
|
|
400
387
|
});
|
|
401
388
|
}
|
|
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
|
-
}
|