@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.
- 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 +163 -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 +93 -75
- 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 +113 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Single responsibility: continuing an `agent()` run — the transcript handle a finished run
|
|
2
|
+
// leaves, the screen it passes when an app hands it back, the one user turn a continuation
|
|
3
|
+
// appends, and `.run()` / `.continue()` on the action.
|
|
4
|
+
//
|
|
5
|
+
// It exists because the alternative is a whole new run: repairing one section of an article
|
|
6
|
+
// re-pays the transcript and every tool result, and the model starts from nothing. A continuation
|
|
7
|
+
// re-SENDS the transcript (a provider has no session), but byte-for-byte, so the provider's prompt
|
|
8
|
+
// cache reads the prefix back at the cache rate, and no tool runs twice.
|
|
9
|
+
//
|
|
10
|
+
// The handle is DATA: JSON, stored by the app, version-stamped, bound to one agent, size-capped
|
|
11
|
+
// and re-validated block by block on the way in — a tool_use nothing answers is a 400 from every
|
|
12
|
+
// provider, and a handle is exactly where one would come from. It carries what the model was
|
|
13
|
+
// SENT (already redacted) and what the tools answered, so it is stored like the tool results are.
|
|
14
|
+
|
|
15
|
+
import type { Action, InvokeOptions } from '@ultimat3/action';
|
|
16
|
+
import {
|
|
17
|
+
asyncContext,
|
|
18
|
+
CURSOR_SECRET_FIX,
|
|
19
|
+
CURSOR_SECRET_KEY,
|
|
20
|
+
canonicalJson,
|
|
21
|
+
currentSigningSecret,
|
|
22
|
+
fingerprint,
|
|
23
|
+
isProduction,
|
|
24
|
+
isUltimateError,
|
|
25
|
+
usesDevCursorSecret,
|
|
26
|
+
} from '@ultimat3/core';
|
|
27
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
28
|
+
import { AgentTranscriptInvalidError } from './agent-errors';
|
|
29
|
+
import type { AgentRunUsage } from './agent-usage';
|
|
30
|
+
import type { AiContentBlock, AiMessage, TokenUsage } from './provider';
|
|
31
|
+
import { RESPOND } from './respond';
|
|
32
|
+
|
|
33
|
+
/** The handle's format. A handle of another version is refused, never migrated in place. */
|
|
34
|
+
export const TRANSCRIPT_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The most a handle may weigh, in characters of its JSON. Every continuation re-sends all of it,
|
|
38
|
+
* so this is a spend ceiling as much as a storage one: 2,000,000 characters is about 500k tokens
|
|
39
|
+
* of input per turn. A run that outgrew it starts a new run.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_TRANSCRIPT_CHARS = 2_000_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A run, as the next call needs it. OPAQUE to an app — store the value (it is JSON), pass it back
|
|
45
|
+
* to `.continue()`; the fields are this package's and may change with `v`.
|
|
46
|
+
*
|
|
47
|
+
* SIGNED, and bound to what must not change between the run and its continuation. The handle
|
|
48
|
+
* carries the run's own ceilings (`turns`, `usage`) and its transcript, and it travels through an
|
|
49
|
+
* app's storage — so every field is under an HMAC, and a handle anyone edited is refused.
|
|
50
|
+
*/
|
|
51
|
+
export interface AgentTranscript {
|
|
52
|
+
readonly v: typeof TRANSCRIPT_VERSION;
|
|
53
|
+
/** The agent's prompt ref (`id@version`). A handle continues the agent that wrote it, only. */
|
|
54
|
+
readonly agent: string;
|
|
55
|
+
/** The model the run was on. A transcript is not carried to another one. */
|
|
56
|
+
readonly model: string;
|
|
57
|
+
/** A fingerprint of the tool names the agent declared when the handle was written. */
|
|
58
|
+
readonly tools: string;
|
|
59
|
+
/** The tenant of the actor the run was FOR, or `null`. Another org's actor is refused. */
|
|
60
|
+
readonly org: string | null;
|
|
61
|
+
/** Everything sent and answered so far. */
|
|
62
|
+
readonly messages: readonly AiMessage[];
|
|
63
|
+
/** Model turns taken across the run and every continuation — what `maxTurns` counts. */
|
|
64
|
+
readonly turns: number;
|
|
65
|
+
/** Usage across the run and every continuation — what `budget.tokensPerRun` counts. */
|
|
66
|
+
readonly usage: TokenUsage;
|
|
67
|
+
/** HMAC-SHA256 over the canonical JSON of every field above (`sealTranscript`). */
|
|
68
|
+
readonly sig: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a handle is bound to: read off the DECLARATION and the request, never off the handle. */
|
|
72
|
+
export interface TranscriptBinding {
|
|
73
|
+
readonly agent: string;
|
|
74
|
+
readonly model: string;
|
|
75
|
+
/** Every tool name the agent offers, `respond` included. */
|
|
76
|
+
readonly tools: readonly string[];
|
|
77
|
+
readonly org: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What `.run()` and `.continue()` resolve to: the validated answer and the handle to go on from. */
|
|
81
|
+
export interface AgentRun<TOutput> {
|
|
82
|
+
readonly output: TOutput;
|
|
83
|
+
readonly transcript: AgentTranscript;
|
|
84
|
+
/** Turns taken so far, continuations included. */
|
|
85
|
+
readonly turns: number;
|
|
86
|
+
/** Usage and cost so far, continuations included — priced once, from the summed usage. */
|
|
87
|
+
readonly usage: AgentRunUsage;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AgentContinuation {
|
|
91
|
+
/** The handle a previous `.run()` or `.continue()` returned. */
|
|
92
|
+
readonly transcript: AgentTranscript;
|
|
93
|
+
/** The one user turn to append: what to change, what was wrong. Redacted like the prompt. */
|
|
94
|
+
readonly user: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An `agent()` action, plus the two calls that keep the transcript. */
|
|
98
|
+
export interface AgentAction<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
99
|
+
extends Action<TInput, TOutput> {
|
|
100
|
+
/**
|
|
101
|
+
* The SAME action — same policy, audit, rate limit, budget — returning the transcript handle
|
|
102
|
+
* beside the answer. A plain call returns the answer alone and keeps nothing.
|
|
103
|
+
*/
|
|
104
|
+
run(input: InferInput<TInput>, opts?: InvokeOptions): Promise<AgentRun<InferOutput<TOutput>>>;
|
|
105
|
+
/**
|
|
106
|
+
* Resume a finished run with one more user turn. `input` is the run's own input again (the
|
|
107
|
+
* policy and `system()` read it); the prompt is NOT re-rendered — the transcript holds it.
|
|
108
|
+
* `maxTurns` and `budget.tokensPerRun` count the run and every continuation together;
|
|
109
|
+
* `deadlineMs` and `maxRepeatedToolCalls` are per call.
|
|
110
|
+
*/
|
|
111
|
+
continue(
|
|
112
|
+
input: InferInput<TInput>,
|
|
113
|
+
resume: AgentContinuation,
|
|
114
|
+
opts?: InvokeOptions,
|
|
115
|
+
): Promise<AgentRun<InferOutput<TOutput>>>;
|
|
116
|
+
named(name: string): AgentAction<TInput, TOutput>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One `.run()` / `.continue()` in flight: what to resume from, and where the loop leaves its run. */
|
|
120
|
+
export interface AgentSession {
|
|
121
|
+
/**
|
|
122
|
+
* Which DECLARATION this session was opened for. The session rides the async context, so every
|
|
123
|
+
* agent that runs underneath — a tool that calls another agent — can see it; only the one whose
|
|
124
|
+
* token this is may take it, and only once.
|
|
125
|
+
*/
|
|
126
|
+
readonly owner: object;
|
|
127
|
+
claimed: boolean;
|
|
128
|
+
readonly resume: AgentContinuation | undefined;
|
|
129
|
+
finished: Omit<AgentRun<unknown>, 'output'> | undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Core's lazy seam, like `llm-stream.ts`'s sink: the mark rides the invocation, so the action a
|
|
133
|
+
// continuation calls is the one object the registry, the policy and the audit record know.
|
|
134
|
+
const sessions = asyncContext<AgentSession>('an agent session');
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The session of THIS run, taken — or `undefined` for a plain call, and for any other agent
|
|
138
|
+
* running under somebody else's `.run()` / `.continue()`: a nested agent starts fresh.
|
|
139
|
+
*/
|
|
140
|
+
export function claimAgentSession(owner: object): AgentSession | undefined {
|
|
141
|
+
const session = sessions.get();
|
|
142
|
+
if (session === undefined || session.owner !== owner || session.claimed) return undefined;
|
|
143
|
+
session.claimed = true;
|
|
144
|
+
return session;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `.run()` and `.continue()` for one action: each invokes THAT action inside a session. */
|
|
148
|
+
export function sessionCalls<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
149
|
+
target: Action<TInput, TOutput>,
|
|
150
|
+
/** The declaration's own token: `named()` twins share it, no other agent has it. */
|
|
151
|
+
owner: object,
|
|
152
|
+
): Pick<AgentAction<TInput, TOutput>, 'run' | 'continue'> {
|
|
153
|
+
const invoke = async (
|
|
154
|
+
input: InferInput<TInput>,
|
|
155
|
+
resume: AgentContinuation | undefined,
|
|
156
|
+
opts: InvokeOptions | undefined,
|
|
157
|
+
): Promise<AgentRun<InferOutput<TOutput>>> => {
|
|
158
|
+
const session: AgentSession = { owner, claimed: false, resume, finished: undefined };
|
|
159
|
+
const output = await sessions.run(session, () => target(input, opts ?? {}));
|
|
160
|
+
const finished = session.finished;
|
|
161
|
+
if (finished === undefined) {
|
|
162
|
+
// The action answered without the loop running: something wrapped it and short-circuited.
|
|
163
|
+
throw new AgentTranscriptInvalidError({
|
|
164
|
+
agent: target.name ?? 'agent',
|
|
165
|
+
reason: 'the action returned without running the agent loop, so there is no transcript',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return { output, ...finished };
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
run: (input, opts) => invoke(input, undefined, opts),
|
|
172
|
+
continue: (input, resume, opts) => invoke(input, resume, opts),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The transcript with ONE user turn appended. Every `tool_use` the last assistant turn left open is
|
|
178
|
+
* answered first — the accepted `respond` call among them, which the API insists on seeing
|
|
179
|
+
* answered like any other — and the instruction follows as text.
|
|
180
|
+
*
|
|
181
|
+
* A handle from a FAILED call ends on a user message (the request the failed turn was answering).
|
|
182
|
+
* Roles must alternate, so the instruction joins that message as one more text block.
|
|
183
|
+
*/
|
|
184
|
+
export function continued(transcript: AgentTranscript, user: string): readonly AiMessage[] {
|
|
185
|
+
const { messages } = transcript;
|
|
186
|
+
const last = messages.at(-1);
|
|
187
|
+
const text = { type: 'text' as const, text: user };
|
|
188
|
+
if (last === undefined) return [{ role: 'user', content: user }];
|
|
189
|
+
if (last.role === 'user') {
|
|
190
|
+
const blocks =
|
|
191
|
+
typeof last.content === 'string'
|
|
192
|
+
? [{ type: 'text' as const, text: last.content }]
|
|
193
|
+
: last.content;
|
|
194
|
+
return [...messages.slice(0, -1), { role: 'user', content: [...blocks, text] }];
|
|
195
|
+
}
|
|
196
|
+
const open =
|
|
197
|
+
typeof last.content === 'string' ? [] : last.content.filter((b) => b.type === 'tool_use');
|
|
198
|
+
if (open.length === 0) return [...messages, { role: 'user', content: user }];
|
|
199
|
+
const answers = open.map((call) => ({
|
|
200
|
+
type: 'tool_result' as const,
|
|
201
|
+
tool_use_id: call.id,
|
|
202
|
+
content:
|
|
203
|
+
call.name === RESPOND ? 'Accepted as the answer so far.' : 'Not run: the turn had ended.',
|
|
204
|
+
}));
|
|
205
|
+
return [...messages, { role: 'user', content: [...answers, text] }];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Domain separation: this key signs transcripts and nothing else the same secret signs. */
|
|
209
|
+
const PURPOSE = 'ultimate.ai.agent-transcript.v1';
|
|
210
|
+
|
|
211
|
+
const toolsMark = (names: readonly string[]): string => fingerprint([...names].sort());
|
|
212
|
+
|
|
213
|
+
function signatureOf(fields: Omit<AgentTranscript, 'sig'>): string {
|
|
214
|
+
// The framework's one signing secret (`ULTIMATE_CURSOR_SECRET`), under this file's own label.
|
|
215
|
+
return new Bun.CryptoHasher('sha256', currentSigningSecret())
|
|
216
|
+
.update(`${PURPOSE}\n${canonicalJson(fields)}`)
|
|
217
|
+
.digest('hex');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A handle for `state`, bound and signed. The one place a transcript is written. */
|
|
221
|
+
export function sealTranscript(
|
|
222
|
+
binding: TranscriptBinding,
|
|
223
|
+
state: Pick<AgentTranscript, 'messages' | 'turns' | 'usage'>,
|
|
224
|
+
): AgentTranscript {
|
|
225
|
+
const fields: Omit<AgentTranscript, 'sig'> = {
|
|
226
|
+
v: TRANSCRIPT_VERSION,
|
|
227
|
+
agent: binding.agent,
|
|
228
|
+
model: binding.model,
|
|
229
|
+
tools: toolsMark(binding.tools),
|
|
230
|
+
org: binding.org,
|
|
231
|
+
messages: state.messages,
|
|
232
|
+
turns: state.turns,
|
|
233
|
+
usage: state.usage,
|
|
234
|
+
};
|
|
235
|
+
return { ...fields, sig: signatureOf(fields) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The `meta` key a failed `.run()` / `.continue()` carries its handle under. */
|
|
239
|
+
export const TRANSCRIPT_META = 'transcript';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The handle a FAILED `.run()` / `.continue()` left: the transcript up to the request the failed
|
|
243
|
+
* turn was answering, with `turns` and `usage` counting everything the failed call was billed. A
|
|
244
|
+
* job stores THIS one — continuing from the handle it started with forgets that spend, and is
|
|
245
|
+
* then bounded by the actor and org ceilings alone. `undefined` when the failure left none.
|
|
246
|
+
*/
|
|
247
|
+
export function transcriptOf(error: unknown): AgentTranscript | undefined {
|
|
248
|
+
if (!isUltimateError(error)) return undefined;
|
|
249
|
+
const raw = recordOf(error.meta?.[TRANSCRIPT_META]);
|
|
250
|
+
return raw !== undefined && typeof raw['sig'] === 'string'
|
|
251
|
+
? (raw as unknown as AgentTranscript)
|
|
252
|
+
: undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** `error`, carrying `transcript` on `meta.transcript` — written in place, as its bill is. */
|
|
256
|
+
export function withTranscript(error: unknown, transcript: AgentTranscript): unknown {
|
|
257
|
+
if (isUltimateError(error)) {
|
|
258
|
+
try {
|
|
259
|
+
Reflect.set(error, 'meta', { ...error.meta, [TRANSCRIPT_META]: transcript });
|
|
260
|
+
} catch {
|
|
261
|
+
// A frozen error keeps the meta it was thrown with.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A handle an app handed back, screened — before anything is loaded, reserved or sent. In order:
|
|
269
|
+
* its weight; its SIGNATURE (so `turns`, `usage` and every message are the ones this process
|
|
270
|
+
* wrote); what it is bound to — the agent, the model, the declared tools, the actor's tenant; and
|
|
271
|
+
* then its shape anyway, because a signed handle from an older build is still an older build's.
|
|
272
|
+
*/
|
|
273
|
+
export function screenTranscript(binding: TranscriptBinding, raw: unknown): AgentTranscript {
|
|
274
|
+
const { agent } = binding;
|
|
275
|
+
const refuse = (reason: string, fix?: string): never => {
|
|
276
|
+
throw new AgentTranscriptInvalidError({ agent, reason, ...(fix === undefined ? {} : { fix }) });
|
|
277
|
+
};
|
|
278
|
+
// A handle signed with the shipped development key is one anybody can forge.
|
|
279
|
+
if (isProduction() && usesDevCursorSecret()) {
|
|
280
|
+
refuse(
|
|
281
|
+
`this process signs with the shipped development key, so a handle cannot be trusted — set ${CURSOR_SECRET_KEY}`,
|
|
282
|
+
CURSOR_SECRET_FIX,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const handle = recordOf(raw) ?? refuse('it is not an object');
|
|
286
|
+
if (handle['v'] !== TRANSCRIPT_VERSION) {
|
|
287
|
+
refuse(`it is version ${String(handle['v'])}, and this build reads ${TRANSCRIPT_VERSION}`);
|
|
288
|
+
}
|
|
289
|
+
let weight: number;
|
|
290
|
+
try {
|
|
291
|
+
weight = JSON.stringify(handle).length;
|
|
292
|
+
} catch {
|
|
293
|
+
return refuse('it is not JSON');
|
|
294
|
+
}
|
|
295
|
+
if (weight > MAX_TRANSCRIPT_CHARS) {
|
|
296
|
+
refuse(`it is ${weight} characters, over the ${MAX_TRANSCRIPT_CHARS} a handle may weigh`);
|
|
297
|
+
}
|
|
298
|
+
const { sig, ...fields } = handle;
|
|
299
|
+
let expected: string;
|
|
300
|
+
try {
|
|
301
|
+
expected = signatureOf(fields as unknown as Omit<AgentTranscript, 'sig'>);
|
|
302
|
+
} catch {
|
|
303
|
+
return refuse('it is not JSON');
|
|
304
|
+
}
|
|
305
|
+
if (typeof sig !== 'string' || !sameText(sig, expected)) {
|
|
306
|
+
refuse('its signature does not match — it was edited, or the signing secret rotated');
|
|
307
|
+
}
|
|
308
|
+
if (handle['agent'] !== agent) refuse(`it was written by agent "${String(handle['agent'])}"`);
|
|
309
|
+
if (handle['model'] !== binding.model) {
|
|
310
|
+
refuse(
|
|
311
|
+
`it was written on model "${String(handle['model'])}", and this call is on "${binding.model}"`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (handle['tools'] !== toolsMark(binding.tools)) {
|
|
315
|
+
refuse('the agent declared a different set of tools when it was written');
|
|
316
|
+
}
|
|
317
|
+
if (handle['org'] !== binding.org) refuse('it was written for another tenant');
|
|
318
|
+
const turns = handle['turns'];
|
|
319
|
+
if (typeof turns !== 'number' || !Number.isInteger(turns) || turns < 0) {
|
|
320
|
+
refuse('its turn count is not a whole number');
|
|
321
|
+
}
|
|
322
|
+
const usage = usageOf(handle['usage']) ?? refuse('its usage is not four token counts');
|
|
323
|
+
const messages = Array.isArray(handle['messages']) ? handle['messages'] : refuse('no messages');
|
|
324
|
+
if (messages.length < 1) refuse('it holds no message');
|
|
325
|
+
const declared = new Set(binding.tools);
|
|
326
|
+
let open: ReadonlySet<string> = new Set();
|
|
327
|
+
const screened = messages.map((entry: unknown, index: number): AiMessage => {
|
|
328
|
+
const message = recordOf(entry) ?? refuse(`messages[${index}] is not an object`);
|
|
329
|
+
const role = index % 2 === 0 ? 'user' : 'assistant';
|
|
330
|
+
if (message['role'] !== role) refuse(`messages[${index}] is not a ${role} message`);
|
|
331
|
+
const content = contentOf(message['content']) ?? refuse(`messages[${index}] has no content`);
|
|
332
|
+
const blocks = typeof content === 'string' ? [] : content;
|
|
333
|
+
const answered = new Set(
|
|
334
|
+
blocks.flatMap((b) => (b.type === 'tool_result' ? [b.tool_use_id] : [])),
|
|
335
|
+
);
|
|
336
|
+
for (const id of open) {
|
|
337
|
+
if (!answered.has(id)) refuse(`messages[${index}] does not answer tool call "${id}"`);
|
|
338
|
+
}
|
|
339
|
+
if (role === 'user' && answered.size > open.size) {
|
|
340
|
+
refuse(`messages[${index}] answers a tool call nothing made`);
|
|
341
|
+
}
|
|
342
|
+
if (role === 'user' && blocks.some((b) => b.type === 'tool_use')) {
|
|
343
|
+
refuse(`messages[${index}] is a user message carrying a tool call`);
|
|
344
|
+
}
|
|
345
|
+
if (role === 'assistant' && answered.size > 0) {
|
|
346
|
+
refuse(`messages[${index}] is an assistant message carrying a tool result`);
|
|
347
|
+
}
|
|
348
|
+
for (const block of blocks) {
|
|
349
|
+
if (block.type === 'tool_use' && !declared.has(block.name)) {
|
|
350
|
+
refuse(`messages[${index}] calls "${block.name}", which this agent does not declare`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
open = new Set(blocks.flatMap((b) => (b.type === 'tool_use' ? [b.id] : [])));
|
|
354
|
+
return { role, content };
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
v: TRANSCRIPT_VERSION,
|
|
358
|
+
agent,
|
|
359
|
+
model: binding.model,
|
|
360
|
+
tools: handle['tools'] as string,
|
|
361
|
+
org: binding.org,
|
|
362
|
+
messages: screened,
|
|
363
|
+
turns: turns as number,
|
|
364
|
+
usage,
|
|
365
|
+
sig: sig as string,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Constant-time over equal lengths: a signature check must not say how much of it matched. */
|
|
370
|
+
function sameText(a: string, b: string): boolean {
|
|
371
|
+
if (a.length !== b.length) return false;
|
|
372
|
+
let diff = 0;
|
|
373
|
+
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
374
|
+
return diff === 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
378
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
379
|
+
? (value as Record<string, unknown>)
|
|
380
|
+
: undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const COUNTERS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const;
|
|
384
|
+
|
|
385
|
+
function usageOf(raw: unknown): TokenUsage | undefined {
|
|
386
|
+
const record = recordOf(raw);
|
|
387
|
+
if (record === undefined) return undefined;
|
|
388
|
+
const count = (key: (typeof COUNTERS)[number]): number | undefined => {
|
|
389
|
+
const value = record[key];
|
|
390
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
391
|
+
};
|
|
392
|
+
const [input, output, read, write] = COUNTERS.map(count);
|
|
393
|
+
if (input === undefined || output === undefined || read === undefined || write === undefined) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
inputTokens: input,
|
|
398
|
+
outputTokens: output,
|
|
399
|
+
cacheReadTokens: read,
|
|
400
|
+
cacheWriteTokens: write,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A message's content, rebuilt field by field: a string, or the three block kinds a loop writes. */
|
|
405
|
+
function contentOf(raw: unknown): string | readonly AiContentBlock[] | undefined {
|
|
406
|
+
if (typeof raw === 'string') return raw === '' ? undefined : raw;
|
|
407
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
408
|
+
const blocks: AiContentBlock[] = [];
|
|
409
|
+
for (const entry of raw) {
|
|
410
|
+
const block = recordOf(entry);
|
|
411
|
+
const built = block === undefined ? undefined : blockOf(block);
|
|
412
|
+
if (built === undefined) return undefined;
|
|
413
|
+
blocks.push(built);
|
|
414
|
+
}
|
|
415
|
+
return blocks;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function blockOf(block: Record<string, unknown>): AiContentBlock | undefined {
|
|
419
|
+
const { type, text, id, name, tool_use_id: answers, content } = block;
|
|
420
|
+
if (type === 'text' && typeof text === 'string' && text !== '') return { type, text };
|
|
421
|
+
const input = recordOf(block['input']);
|
|
422
|
+
if (type === 'tool_use' && typeof id === 'string' && typeof name === 'string' && input) {
|
|
423
|
+
return { type, id, name, input };
|
|
424
|
+
}
|
|
425
|
+
if (type === 'tool_result' && typeof answers === 'string' && typeof content === 'string') {
|
|
426
|
+
return {
|
|
427
|
+
type,
|
|
428
|
+
tool_use_id: answers,
|
|
429
|
+
content,
|
|
430
|
+
...(block['is_error'] === true ? { is_error: true } : {}),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Single responsibility: what an `agent()` declaration AMOUNTS to before a run exists — its
|
|
2
|
+
// defaults, the screen its bounds pass at declaration, the budget limits one call runs under, and
|
|
3
|
+
// the row `describeAgents()` publishes. Split from ./agent at its line ceiling.
|
|
4
|
+
|
|
5
|
+
import { finiteCount, isMcpExposed } from '@ultimat3/core';
|
|
6
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import type { AgentDef } from './agent-def';
|
|
8
|
+
import type { AgentFact } from './agent-facts';
|
|
9
|
+
import { DEFAULT_MAX_REPEATED_TOOL_CALLS } from './agent-guards';
|
|
10
|
+
import type { BudgetLimits } from './budget';
|
|
11
|
+
import { screenedMoney } from './budget';
|
|
12
|
+
import { AiBudgetExceededError, AiRequestInvalidError } from './errors';
|
|
13
|
+
import { describedModel, type ModelSource } from './model-resolve';
|
|
14
|
+
import type { PromptVars } from './prompt';
|
|
15
|
+
import type { TokenUsage } from './provider';
|
|
16
|
+
import { totalTokens } from './provider';
|
|
17
|
+
import { installedGateway } from './runtime';
|
|
18
|
+
import { toolLabel } from './tools';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
|
|
22
|
+
* is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
|
|
23
|
+
* grows quadratically in turns rather than linearly.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_TURNS = 8;
|
|
26
|
+
|
|
27
|
+
/** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
|
|
28
|
+
export const DEFAULT_MAX_TOKENS = 4_096;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
|
|
32
|
+
* spends the whole context window on turn two and every later turn re-sends it — the transcript
|
|
33
|
+
* is the request, so an untruncated result is billed once per remaining turn.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_TOOL_RESULT_CHARS = 4_000;
|
|
36
|
+
|
|
37
|
+
/** Named in every bound refusal, so the fix names the key the declaration carries. */
|
|
38
|
+
export const SUBJECT = 'agent';
|
|
39
|
+
|
|
40
|
+
/** The loop's bounds with their defaults applied — read by the run from HERE, never re-defaulted. */
|
|
41
|
+
export interface AgentBounds {
|
|
42
|
+
readonly maxTurns: number;
|
|
43
|
+
readonly maxToolResultChars: number;
|
|
44
|
+
readonly maxTokens: number;
|
|
45
|
+
readonly maxRepeatedToolCalls: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The loop bounds, screened at DECLARATION: `agent()` runs at module scope, so a bad one fails the boot. */
|
|
49
|
+
export function screenBounds<
|
|
50
|
+
TInput extends StandardSchemaV1,
|
|
51
|
+
TOutput extends StandardSchemaV1,
|
|
52
|
+
V extends PromptVars,
|
|
53
|
+
>(def: AgentDef<TInput, TOutput, V>): AgentBounds {
|
|
54
|
+
// The three loop bounds, screened at DECLARATION for the same reason the tool check above is:
|
|
55
|
+
// the values are here, and an `agent()` runs at module scope, so a bound that is not one fails
|
|
56
|
+
// the boot instead of the ninetieth second of a run. None of them is checked by what it lands
|
|
57
|
+
// on — `turn <= NaN` is false on the FIRST comparison, so the loop takes zero turns and reports
|
|
58
|
+
// `X_AGENT_MAX_TURNS` about a model that was never called; `slice(0, NaN)` is `''`, so every
|
|
59
|
+
// tool result reaches the model empty while the run keeps paying for turns; and `maxTokens`
|
|
60
|
+
// becomes the pre-flight estimate, where a `NaN` disables the ledger for the whole process.
|
|
61
|
+
const maxTurns = finiteCount(SUBJECT, 'maxTurns', def.maxTurns ?? DEFAULT_MAX_TURNS, 1);
|
|
62
|
+
const maxToolResultChars = finiteCount(
|
|
63
|
+
SUBJECT,
|
|
64
|
+
'maxToolResultChars',
|
|
65
|
+
def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
66
|
+
1,
|
|
67
|
+
);
|
|
68
|
+
const maxTokens = finiteCount(SUBJECT, 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
|
|
69
|
+
const repeats = finiteCount(
|
|
70
|
+
SUBJECT,
|
|
71
|
+
'maxRepeatedToolCalls',
|
|
72
|
+
def.maxRepeatedToolCalls ?? DEFAULT_MAX_REPEATED_TOOL_CALLS,
|
|
73
|
+
1,
|
|
74
|
+
);
|
|
75
|
+
if (repeats < 2) {
|
|
76
|
+
// One "repeat" is the first tool turn of every run: the agent could never use a tool.
|
|
77
|
+
throw new AiRequestInvalidError({
|
|
78
|
+
detail: `agent maxRepeatedToolCalls is ${repeats}, which would stop every run at its first tool turn`,
|
|
79
|
+
fix: 'maxRepeatedToolCalls: 3 # on the agent — at least 2; it counts consecutive identical turns, the first included',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// `AbortSignal.timeout(NaN)` throws a RangeError mid-run; `0` aborts before turn one.
|
|
83
|
+
if (def.deadlineMs !== undefined) finiteCount(SUBJECT, 'deadlineMs', def.deadlineMs, 1);
|
|
84
|
+
return { maxTurns, maxToolResultChars, maxTokens, maxRepeatedToolCalls: repeats };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `null`, not `undefined`, for a row naming no model yet: the row is a published fact, and an
|
|
88
|
+
// absent key reads as a reader that forgot to ask.
|
|
89
|
+
const modelFact = (described: {
|
|
90
|
+
readonly model: string | undefined;
|
|
91
|
+
readonly from: ModelSource | undefined;
|
|
92
|
+
}): Pick<AgentFact, 'model' | 'modelFrom'> => ({
|
|
93
|
+
model: described.model ?? null,
|
|
94
|
+
modelFrom: described.from ?? null,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export function factsOf<
|
|
98
|
+
TInput extends StandardSchemaV1,
|
|
99
|
+
TOutput extends StandardSchemaV1,
|
|
100
|
+
V extends PromptVars,
|
|
101
|
+
>(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
|
|
102
|
+
const budget = def.budget;
|
|
103
|
+
return {
|
|
104
|
+
prompt: def.prompt.ref,
|
|
105
|
+
promptId: def.prompt.id,
|
|
106
|
+
promptHash: def.prompt.hash,
|
|
107
|
+
// The run's own precedence, so the row names the model a call would use NOW: read when the
|
|
108
|
+
// facts are, at describe time, like every name in the row. `modelFrom` says which place
|
|
109
|
+
// answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
|
|
110
|
+
...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
|
|
111
|
+
maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
112
|
+
maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
113
|
+
tools: [...def.tools.map(toolLabel)].sort(),
|
|
114
|
+
budget: {
|
|
115
|
+
tokensIn: budget?.tokensIn ?? null,
|
|
116
|
+
tokensPerRun: budget?.tokensPerRun ?? null,
|
|
117
|
+
costPerCall: budget?.costPerCall ?? null,
|
|
118
|
+
},
|
|
119
|
+
mcp: isMcpExposed(def.mcp),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function limitsOf<
|
|
124
|
+
TInput extends StandardSchemaV1,
|
|
125
|
+
TOutput extends StandardSchemaV1,
|
|
126
|
+
V extends PromptVars,
|
|
127
|
+
>(def: AgentDef<TInput, TOutput, V>, used?: TokenUsage): BudgetLimits {
|
|
128
|
+
const budget = def.budget;
|
|
129
|
+
return {
|
|
130
|
+
// Screened under the names the DECLARATION uses, not the ledger's: `tokensPerRun` arrives at
|
|
131
|
+
// `BudgetLimits` as `request`, and a fix line naming a key the app never wrote is not a fix.
|
|
132
|
+
...(budget?.tokensIn === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
|
|
135
|
+
...(budget?.costPerCall === undefined
|
|
136
|
+
? {}
|
|
137
|
+
: { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
|
|
138
|
+
// The ledger's `request` scope accumulates across every call made under one ledger, which for
|
|
139
|
+
// a run under `withBudget` is exactly "the whole run".
|
|
140
|
+
...(budget?.tokensPerRun === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: {
|
|
143
|
+
request: leftOf(finiteCount(SUBJECT, 'budget.tokensPerRun', budget.tokensPerRun), used),
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `tokensPerRun` for THIS call: the declared ceiling less what the run's earlier calls used. Never
|
|
150
|
+
* a debit on the ledger — that would bill the actor and org scopes a second time for tokens they
|
|
151
|
+
* were already charged — so the remainder is the ceiling, and none left is refused before a call.
|
|
152
|
+
*/
|
|
153
|
+
function leftOf(limit: number, used: TokenUsage | undefined): number {
|
|
154
|
+
const spent = used === undefined ? 0 : totalTokens(used);
|
|
155
|
+
if (spent < limit) return limit - spent;
|
|
156
|
+
throw new AiBudgetExceededError({ scope: 'request', requested: 1, remaining: 0, limit });
|
|
157
|
+
}
|
package/src/agent-def.ts
CHANGED
|
@@ -52,12 +52,19 @@ export interface AgentDef<
|
|
|
52
52
|
*/
|
|
53
53
|
readonly promptCache?: PromptCacheOption<AgentVarsArgs<TInput>>;
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
* external MCP client is filtered by, so
|
|
57
|
-
*
|
|
58
|
-
* dropped
|
|
55
|
+
* What the model may call: an `action()` that is `mcp: { expose: true }` — the same predicate an
|
|
56
|
+
* external MCP client is filtered by, so both are offered the same actions — or an
|
|
57
|
+
* `inProcessTool()`, which only this process's agents ever see. Anything else is refused at
|
|
58
|
+
* declaration rather than dropped: a tool that reads as offered and silently is not is the worst
|
|
59
|
+
* of both.
|
|
59
60
|
*/
|
|
60
61
|
readonly tools: readonly AgentTool[];
|
|
62
|
+
/**
|
|
63
|
+
* `false` withholds the refusals of EVERY in-process tool this agent runs, as a projected tool's
|
|
64
|
+
* are — for an agent whose transcript leaves the tenant it ran for. Default `true`; one tool
|
|
65
|
+
* opts out with `inProcessTool({ discloseErrors: false })`.
|
|
66
|
+
*/
|
|
67
|
+
readonly discloseToolErrors?: boolean;
|
|
61
68
|
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
62
69
|
readonly maxTurns?: number;
|
|
63
70
|
readonly maxToolResultChars?: number;
|
|
@@ -76,6 +83,15 @@ export interface AgentDef<
|
|
|
76
83
|
readonly mcp?: ActionMcp;
|
|
77
84
|
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
78
85
|
readonly maxTokens?: number;
|
|
86
|
+
/**
|
|
87
|
+
* `temperature` (0 to 2) and `seed` (a whole number), for a call that should answer the same way
|
|
88
|
+
* twice — a verifier, a classifier. Sent only to a model whose row takes them
|
|
89
|
+
* (`registerModel({ sampling })`); to any other they are NOT sent, because a reasoning model
|
|
90
|
+
* answers one with a 400 — the `llm.sampling.ignored` span attribute names what was left out.
|
|
91
|
+
* `seed` exists on the chat-completions wire only, and is best effort where it exists.
|
|
92
|
+
*/
|
|
93
|
+
readonly temperature?: number;
|
|
94
|
+
readonly seed?: number;
|
|
79
95
|
/**
|
|
80
96
|
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
81
97
|
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
@@ -104,8 +120,15 @@ export interface AgentTurn {
|
|
|
104
120
|
readonly toolCalls: readonly string[];
|
|
105
121
|
readonly stopReason: StopReason;
|
|
106
122
|
readonly usage: TokenUsage;
|
|
107
|
-
/** This turn's cost alone, integer minor units — never the run's running total. */
|
|
123
|
+
/** This turn's cost alone, integer minor units, rounded up — never the run's running total. */
|
|
108
124
|
readonly cost: Money;
|
|
125
|
+
/** This turn's cost exactly (`Money` at a finer `scale`); absent when the provider stated none. */
|
|
126
|
+
readonly costExact?: Money;
|
|
127
|
+
/**
|
|
128
|
+
* Set on a REPAIR turn: the schema issues of the previous turn's answer, which is what the
|
|
129
|
+
* model was told to fix before it produced this one. Absent on every other turn.
|
|
130
|
+
*/
|
|
131
|
+
readonly repairOf?: readonly string[];
|
|
109
132
|
/** The running total, this turn included — on the last turn, the whole run. */
|
|
110
133
|
readonly run: AgentRunUsage;
|
|
111
134
|
}
|
package/src/agent-errors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The X_AGENT_* codes an UNATTENDED run needs, and the one a continuation's handle is refused by, apart from ./errors only because one file has
|
|
2
2
|
// one job and that catalogue is at its ceiling — the split `hive-errors.ts` made. The codes, their
|
|
3
3
|
// titles and the single `registerErrorCodes` call stay in ./errors.
|
|
4
4
|
|
|
@@ -48,3 +48,22 @@ export class AgentDeadlineError extends UltimateError {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A transcript handle handed to `.continue()` did not pass its screen. The handle is data an app
|
|
54
|
+
* stored and read back, so it is refused BEFORE anything is loaded, reserved or sent: a transcript
|
|
55
|
+
* with a tool call nothing answers is a 400 from every provider, and one from another agent or
|
|
56
|
+
* another format would resume a conversation this declaration never had.
|
|
57
|
+
*/
|
|
58
|
+
export class AgentTranscriptInvalidError extends UltimateError {
|
|
59
|
+
constructor(input: { agent: string; reason: string; fix?: string }) {
|
|
60
|
+
super({
|
|
61
|
+
code: 'X_AGENT_TRANSCRIPT_INVALID',
|
|
62
|
+
cause: `agent "${input.agent}" cannot continue from the transcript it was given: ${input.reason}`,
|
|
63
|
+
fix:
|
|
64
|
+
input.fix ??
|
|
65
|
+
'agent.run(input, { ctx }) # start a new run and store ITS transcript — pass back exactly the value .run() or .continue() returned, from the same agent and prompt version',
|
|
66
|
+
meta: { agent: input.agent },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|