@ultimat3/ai 30.9.0 → 31.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +20 -11
- package/README.md +47 -22
- package/package.json +13 -12
- package/src/adapter.ts +21 -0
- package/src/agent-compact.ts +5 -2
- package/src/agent-continue.ts +4 -69
- package/src/agent-def.ts +6 -1
- package/src/agent-session.ts +66 -0
- package/src/agent-system.ts +1 -1
- package/src/agent-tools.ts +123 -0
- package/src/agent-turn.ts +14 -4
- package/src/agent.ts +43 -88
- package/src/budget-store.ts +82 -0
- package/src/budget.ts +7 -78
- package/src/embeddings.ts +1 -1
- package/src/errors.ts +5 -5
- package/src/eval-agent.ts +1 -1
- package/src/gateway-providers.ts +22 -10
- package/src/gateway.ts +20 -21
- package/src/index.ts +6 -71
- package/src/llm-answer.ts +125 -0
- package/src/llm-cache.ts +16 -2
- package/src/llm-def.ts +1 -1
- package/src/llm-instrument.ts +100 -0
- package/src/llm.ts +152 -241
- package/src/openai-provider.ts +2 -1
- package/src/openai-wire-stream.ts +243 -0
- package/src/openai-wire.ts +9 -236
- package/src/pg-vector-sql.ts +30 -2
- package/src/provider.ts +1 -1
- package/src/runtime.ts +83 -38
- package/src/sampling.ts +4 -4
- package/src/scorers.ts +1 -1
- package/src/tools.ts +12 -8
- package/src/vector.ts +3 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One model turn's tool calls, run as a BOUNDED fan-out: at most `concurrency` in flight, results in
|
|
3
|
+
* the order the model emitted the calls. The bound is what stands between an injected or confused
|
|
4
|
+
* model asking for N calls and N concurrent invocations — and N pool checkouts — in one process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
+
import { throwIfAborted } from '@ultimat3/core';
|
|
9
|
+
import type { RunDeadline } from './agent-guards';
|
|
10
|
+
import type { EmitChunk } from './agent-stream';
|
|
11
|
+
import { assistantTurn, toolResultTurn } from './agent-transcript';
|
|
12
|
+
import type { AiMessage, GenerateResult } from './provider';
|
|
13
|
+
import type { LlmToolCall, LlmToolResult, ProjectableAction } from './tools';
|
|
14
|
+
import { runLlmToolCall } from './tools';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calls of one turn in flight at once, unless the agent declares `maxToolConcurrency`. Eight keeps
|
|
18
|
+
* the speed-up of a turn that asked for a handful (serial cost 5x wall clock for five) while a turn
|
|
19
|
+
* that asked for fifty cannot drain a database pool sized for the whole process.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_TOOL_CONCURRENCY = 8;
|
|
22
|
+
|
|
23
|
+
export interface ToolBatchOptions {
|
|
24
|
+
readonly concurrency: number;
|
|
25
|
+
readonly discloseErrors?: boolean;
|
|
26
|
+
/** Asked before EACH call starts — the run's abort and deadline checks: a throw starts nothing more. */
|
|
27
|
+
readonly beforeCall?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs `calls` with at most `concurrency` in flight. Order is by INDEX, not by completion: each
|
|
32
|
+
* worker writes its result into the call's own slot, so a fast tool answering first cannot be paired
|
|
33
|
+
* with a slow tool's call. A worker takes its next call only after `beforeCall` passed, so a run that
|
|
34
|
+
* was aborted or ran out of time mid-batch starts nothing new.
|
|
35
|
+
*/
|
|
36
|
+
export async function runToolBatch(
|
|
37
|
+
tools: readonly ProjectableAction[],
|
|
38
|
+
calls: readonly LlmToolCall[],
|
|
39
|
+
actor: Actor,
|
|
40
|
+
options: ToolBatchOptions,
|
|
41
|
+
): Promise<readonly LlmToolResult[]> {
|
|
42
|
+
const results: LlmToolResult[] = new Array<LlmToolResult>(calls.length);
|
|
43
|
+
const disclosure =
|
|
44
|
+
options.discloseErrors === undefined ? {} : { discloseErrors: options.discloseErrors };
|
|
45
|
+
let next = 0;
|
|
46
|
+
const worker = async (): Promise<void> => {
|
|
47
|
+
while (next < calls.length) {
|
|
48
|
+
const index = next;
|
|
49
|
+
next += 1;
|
|
50
|
+
const call = calls[index];
|
|
51
|
+
if (call === undefined) continue;
|
|
52
|
+
options.beforeCall?.();
|
|
53
|
+
results[index] = await runLlmToolCall(tools, call, actor, disclosure);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const width = Math.max(1, Math.min(options.concurrency, calls.length));
|
|
57
|
+
await Promise.all(Array.from({ length: width }, worker));
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What one run's tool turns share — fixed before the first turn. */
|
|
62
|
+
export interface ToolRun {
|
|
63
|
+
readonly tools: readonly ProjectableAction[];
|
|
64
|
+
readonly ctx: Ctx;
|
|
65
|
+
/** Read ONCE from `ctx`, never from the model: the only identity any tool runs as. */
|
|
66
|
+
readonly actor: Actor;
|
|
67
|
+
readonly deadline: RunDeadline;
|
|
68
|
+
readonly emit: EmitChunk;
|
|
69
|
+
readonly concurrency: number;
|
|
70
|
+
readonly discloseErrors: boolean;
|
|
71
|
+
readonly maxTurns: number;
|
|
72
|
+
readonly chars: number;
|
|
73
|
+
readonly redact: (text: string) => string;
|
|
74
|
+
readonly repeated: (calls: readonly LlmToolCall[], results: readonly LlmToolResult[]) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One turn that asked for tools: run them, stream what happened, refuse a repeating loop, and
|
|
79
|
+
* answer with the transcript the next turn sends. `answers` are the same turn's `respond` calls.
|
|
80
|
+
*/
|
|
81
|
+
export async function toolTurn(
|
|
82
|
+
run: ToolRun,
|
|
83
|
+
turn: number,
|
|
84
|
+
result: GenerateResult,
|
|
85
|
+
requested: readonly LlmToolCall[],
|
|
86
|
+
answers: readonly LlmToolCall[],
|
|
87
|
+
messages: readonly AiMessage[],
|
|
88
|
+
): Promise<readonly AiMessage[]> {
|
|
89
|
+
const { deadline, emit } = run;
|
|
90
|
+
const stop = (): void => {
|
|
91
|
+
throwIfAborted(run.ctx);
|
|
92
|
+
deadline.check();
|
|
93
|
+
};
|
|
94
|
+
stop();
|
|
95
|
+
for (const call of requested) emit({ type: 'tool_call', turn, id: call.id, name: call.name });
|
|
96
|
+
const results = await deadline.guard(
|
|
97
|
+
// Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
|
|
98
|
+
deadline.scope(() =>
|
|
99
|
+
runToolBatch(run.tools, requested, run.actor, {
|
|
100
|
+
concurrency: run.concurrency,
|
|
101
|
+
discloseErrors: run.discloseErrors,
|
|
102
|
+
beforeCall: stop,
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
results.forEach((done, index) => {
|
|
107
|
+
const name = requested[index]?.name ?? '';
|
|
108
|
+
emit({ type: 'tool_result', turn, id: done.toolUseId, name, isError: done.isError === true });
|
|
109
|
+
});
|
|
110
|
+
emit({ type: 'turn', turn, maxTurns: run.maxTurns });
|
|
111
|
+
// Before the next turn is bought: the same calls with the same results cannot lead anywhere a
|
|
112
|
+
// further turn would not also lead.
|
|
113
|
+
run.repeated(requested, results);
|
|
114
|
+
return [
|
|
115
|
+
...messages,
|
|
116
|
+
assistantTurn(result),
|
|
117
|
+
// Parallel tool use — one turn asking for a tool AND answering — is normal, and the answer is
|
|
118
|
+
// speculative: it was written before the result it asked for existed. So the loop continues,
|
|
119
|
+
// and the answer is REJECTED on the wire rather than dropped, because a replayed `respond`
|
|
120
|
+
// block with no `tool_result` is a 400 and the whole run becomes `X_AI_PROVIDER_UNAVAILABLE`.
|
|
121
|
+
toolResultTurn(results, run.chars, answers, run.redact),
|
|
122
|
+
];
|
|
123
|
+
}
|
package/src/agent-turn.ts
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Span } from '@ultimat3/core';
|
|
6
6
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import { SUBJECT } from './agent-declared';
|
|
7
8
|
import type { AgentDef, AgentTurn } from './agent-def';
|
|
9
|
+
import { LlmTruncatedError } from './answer-errors';
|
|
8
10
|
import { LlmRefusedError } from './errors';
|
|
9
11
|
import { moreCapableThan } from './models';
|
|
10
12
|
import type { PromptVars } from './prompt';
|
|
11
13
|
import type { GenerateRequest, GenerateResult } from './provider';
|
|
14
|
+
import { isTruncated } from './provider';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* One turn, reported twice: onto the span every run already opens, and to the declaration's own
|
|
@@ -26,13 +29,13 @@ export async function observeTurn<
|
|
|
26
29
|
span.addEvent('agent.turn', {
|
|
27
30
|
'agent.turn': event.turn,
|
|
28
31
|
'agent.turn.tool_calls': event.toolCalls.length,
|
|
29
|
-
'llm.stop': event.stopReason,
|
|
30
|
-
'llm.cost.minor': event.cost.minor,
|
|
32
|
+
'ultimate.llm.stop': event.stopReason,
|
|
33
|
+
'ultimate.llm.cost.minor': event.cost.minor,
|
|
31
34
|
'agent.turn.repair': event.repairOf !== undefined,
|
|
32
35
|
// What the provider's prompt cache did for this turn — the two numbers that say whether the
|
|
33
36
|
// stable prefix is really stable.
|
|
34
|
-
'llm.cache.read_tokens': event.usage.cacheReadTokens,
|
|
35
|
-
'llm.cache.write_tokens': event.usage.cacheWriteTokens,
|
|
37
|
+
'ultimate.llm.cache.read_tokens': event.usage.cacheReadTokens,
|
|
38
|
+
'ultimate.llm.cache.write_tokens': event.usage.cacheWriteTokens,
|
|
36
39
|
});
|
|
37
40
|
await def.onTurn?.(event);
|
|
38
41
|
}
|
|
@@ -43,6 +46,13 @@ export function assertAnswerable(
|
|
|
43
46
|
name: string,
|
|
44
47
|
request: GenerateRequest,
|
|
45
48
|
): void {
|
|
49
|
+
// A turn cut off at the ceiling carries arguments that stop wherever the ceiling fell — `{}` on
|
|
50
|
+
// both wires for a call that never reached its first byte — so running a tool on them is a side
|
|
51
|
+
// effect the model did not ask for, and validating them accepts an empty answer for any `output`
|
|
52
|
+
// of optional keys. Refused before a tool runs and before the answer is read.
|
|
53
|
+
if (isTruncated(result.stopReason)) {
|
|
54
|
+
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens, subject: SUBJECT });
|
|
55
|
+
}
|
|
46
56
|
if (result.stopReason !== 'refusal') return;
|
|
47
57
|
throw new LlmRefusedError({
|
|
48
58
|
prompt: name,
|
package/src/agent.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `agent()` — a multi-turn tool-using model call, declared as an `action`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* still one server-authoritative operation with an input schema, an output schema and a policy —
|
|
9
|
-
* so this returns an `action`, and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`,
|
|
10
|
-
* its MCP tool (`@ultimat3/mcp`'s `toolFrom`) and its manifest row without a line here.
|
|
4
|
+
* A FACTORY over an existing primitive (`PRIMITIVE_FACTORIES` in `@ultimat3/core`), never a ninth
|
|
5
|
+
* kind: a tool-using run is still one server-authoritative operation with an input schema, an
|
|
6
|
+
* output schema and a policy — so this returns an `action`, and inherits `.openapi()`, `.client()`,
|
|
7
|
+
* `.job()`, `.contract()`, its MCP tool (`toolFrom`) and its manifest row without a line here.
|
|
11
8
|
*
|
|
12
9
|
* It exists because the alternative is a hand-rolled loop outside the framework, and a hand-rolled
|
|
13
10
|
* loop is where the dangerous mistake lives: taking the ACTOR from the model's output. Here the
|
|
@@ -23,7 +20,7 @@
|
|
|
23
20
|
import type { Action } from '@ultimat3/action';
|
|
24
21
|
import { action, noteToolNames } from '@ultimat3/action';
|
|
25
22
|
import type { Ctx } from '@ultimat3/core';
|
|
26
|
-
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
23
|
+
import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
|
|
27
24
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
28
25
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
29
26
|
import { checkpointer } from './agent-checkpoint';
|
|
@@ -32,14 +29,7 @@ import { compactor } from './agent-compact';
|
|
|
32
29
|
import type { CompactPlan } from './agent-compact-def';
|
|
33
30
|
import { screenCompact } from './agent-compact-def';
|
|
34
31
|
import type { AgentAction, TranscriptBinding } from './agent-continue';
|
|
35
|
-
import {
|
|
36
|
-
claimAgentSession,
|
|
37
|
-
continued,
|
|
38
|
-
screenTranscript,
|
|
39
|
-
sealTranscript,
|
|
40
|
-
sessionCalls,
|
|
41
|
-
withTranscript,
|
|
42
|
-
} from './agent-continue';
|
|
32
|
+
import { continued, screenTranscript, sealTranscript, withTranscript } from './agent-continue';
|
|
43
33
|
import { factsOf, limitsOf, SUBJECT, screenBounds } from './agent-declared';
|
|
44
34
|
import type { AgentDef } from './agent-def';
|
|
45
35
|
import { AgentTranscriptInvalidError } from './agent-errors';
|
|
@@ -50,20 +40,23 @@ import { project, resolvePerRun } from './agent-per-run';
|
|
|
50
40
|
import { assertRunCost } from './agent-run-limits';
|
|
51
41
|
import type { TurnSend } from './agent-send';
|
|
52
42
|
import { sendTurn } from './agent-send';
|
|
43
|
+
import { claimAgentSession, sessionCalls } from './agent-session';
|
|
53
44
|
import type { EmitChunk } from './agent-stream';
|
|
54
45
|
import { AgentChunk, streamOf } from './agent-stream';
|
|
55
46
|
import { sameSystem, systemOf } from './agent-system';
|
|
56
|
-
import {
|
|
47
|
+
import type { ToolRun } from './agent-tools';
|
|
48
|
+
import { DEFAULT_TOOL_CONCURRENCY, toolTurn } from './agent-tools';
|
|
49
|
+
import { assistantTurn, repairTurn } from './agent-transcript';
|
|
57
50
|
import { assertAnswerable, observeTurn } from './agent-turn';
|
|
58
51
|
import { runTotal } from './agent-usage';
|
|
59
|
-
import { LlmTruncatedError } from './answer-errors';
|
|
60
52
|
import { budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
61
53
|
import { AgentMaxTurnsError, AgentToolUnexposedError, LlmOutputInvalidError } from './errors';
|
|
62
|
-
import { answerAttributes } from './llm';
|
|
54
|
+
import { answerAttributes } from './llm-answer';
|
|
55
|
+
import { reportedCall } from './llm-instrument';
|
|
63
56
|
import type { PromptVars } from './prompt';
|
|
64
57
|
import { promptCacheHint } from './prompt-cache';
|
|
65
58
|
import type { AiMessage, GenerateRequest } from './provider';
|
|
66
|
-
import { estimateCost
|
|
59
|
+
import { estimateCost } from './provider';
|
|
67
60
|
import { assertNoSecrets } from './redaction';
|
|
68
61
|
import type { Respond } from './respond';
|
|
69
62
|
import { RESPOND, respondFor } from './respond';
|
|
@@ -71,8 +64,7 @@ import { runMeter } from './run-meter';
|
|
|
71
64
|
import { aiGateway, aiRedactor, installedGateway } from './runtime';
|
|
72
65
|
import type { Sampling } from './sampling';
|
|
73
66
|
import { ignoredSamplingAttribute, screenSampling } from './sampling';
|
|
74
|
-
import
|
|
75
|
-
import { isOfferedTool, runLlmToolCall, toolLabel } from './tools';
|
|
67
|
+
import { isOfferedTool, toolLabel } from './tools';
|
|
76
68
|
|
|
77
69
|
export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
|
|
78
70
|
export type {
|
|
@@ -111,6 +103,12 @@ export function agent<
|
|
|
111
103
|
const owner = {};
|
|
112
104
|
const sampling = screenSampling(SUBJECT, def);
|
|
113
105
|
const compact = screenCompact(def.compact, bounds.maxTokens);
|
|
106
|
+
const toolConcurrency = finiteCount(
|
|
107
|
+
SUBJECT,
|
|
108
|
+
'maxToolConcurrency',
|
|
109
|
+
def.maxToolConcurrency ?? DEFAULT_TOOL_CONCURRENCY,
|
|
110
|
+
1,
|
|
111
|
+
);
|
|
114
112
|
// Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
|
|
115
113
|
// scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
|
|
116
114
|
// the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
@@ -132,7 +130,7 @@ export function agent<
|
|
|
132
130
|
def,
|
|
133
131
|
respond,
|
|
134
132
|
await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
|
|
135
|
-
{ sampling, owner, compact, emit },
|
|
133
|
+
{ sampling, owner, compact, emit, toolConcurrency },
|
|
136
134
|
args,
|
|
137
135
|
),
|
|
138
136
|
),
|
|
@@ -143,10 +141,7 @@ export function agent<
|
|
|
143
141
|
return continuable(built, owner);
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
/**
|
|
147
|
-
* Attach `.run()` and `.continue()` IN PLACE, for the reason `llm()`'s `streamable` gives: the
|
|
148
|
-
* object an app exported is the one the registry names, and `named()`'s twin gets them too.
|
|
149
|
-
*/
|
|
144
|
+
/** `.run()` / `.continue()` IN PLACE (as `llm()`'s `streamable`): the export IS the registry's. */
|
|
150
145
|
function continuable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
151
146
|
target: Action<TInput, TOutput>,
|
|
152
147
|
owner: object,
|
|
@@ -172,6 +167,7 @@ async function run<
|
|
|
172
167
|
readonly owner: object;
|
|
173
168
|
readonly compact: CompactPlan | undefined;
|
|
174
169
|
readonly emit: EmitChunk;
|
|
170
|
+
readonly toolConcurrency: number;
|
|
175
171
|
},
|
|
176
172
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
177
173
|
): Promise<InferOutput<TOutput>> {
|
|
@@ -224,8 +220,8 @@ async function run<
|
|
|
224
220
|
'agent.prompt.hash': prompt.hash,
|
|
225
221
|
'agent.tools': adapted.offered.length,
|
|
226
222
|
'agent.max_turns': maxTurns,
|
|
227
|
-
'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
|
|
228
|
-
'llm.prompt_cache': promptCache !== undefined,
|
|
223
|
+
'ultimate.llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
|
|
224
|
+
'ultimate.llm.prompt_cache': promptCache !== undefined,
|
|
229
225
|
});
|
|
230
226
|
// Read when the run ends, not here: the gateway may be what registers the model's row.
|
|
231
227
|
const reportSampling = (): void => {
|
|
@@ -259,7 +255,6 @@ async function run<
|
|
|
259
255
|
// Every provider call below goes through the meter, so whatever ends the run — a ceiling, a
|
|
260
256
|
// deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
|
|
261
257
|
const meter = runMeter();
|
|
262
|
-
const disclosure = { discloseErrors: def.discloseToolErrors !== false };
|
|
263
258
|
// Worst case: the run so far (continuations and nested calls included) plus this request,
|
|
264
259
|
// estimated as the gateway's own reservation will be — at the write rate only where a provider
|
|
265
260
|
// serving the model would mark it. Before every turn, and before a compaction's summary call.
|
|
@@ -297,7 +292,9 @@ async function run<
|
|
|
297
292
|
compacting,
|
|
298
293
|
send: (request) => {
|
|
299
294
|
assertCost(request);
|
|
300
|
-
|
|
295
|
+
// `reportedCall`: each turn is one model call, so each is one `ai.llm` (`llm-instrument.ts`).
|
|
296
|
+
const answered = reportedCall(request.model ?? model, gateway.generate(request));
|
|
297
|
+
return deadline.guard(meter.call(answered)).catch((error: unknown) => {
|
|
301
298
|
// The caller left mid-call: the aborted socket reads as a provider failure, which is
|
|
302
299
|
// RETRYABLE. Say what happened instead.
|
|
303
300
|
throwIfAborted(args.ctx);
|
|
@@ -321,6 +318,19 @@ async function run<
|
|
|
321
318
|
// identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
|
|
322
319
|
// name the identity it acts as, and a loop that let it would be an escalation primitive.
|
|
323
320
|
const { actor } = args.ctx;
|
|
321
|
+
const tools: ToolRun = {
|
|
322
|
+
tools: adapted.tools,
|
|
323
|
+
ctx: args.ctx,
|
|
324
|
+
actor,
|
|
325
|
+
deadline,
|
|
326
|
+
emit,
|
|
327
|
+
concurrency: fixed.toolConcurrency,
|
|
328
|
+
discloseErrors: def.discloseToolErrors !== false,
|
|
329
|
+
maxTurns,
|
|
330
|
+
chars,
|
|
331
|
+
redact,
|
|
332
|
+
repeated,
|
|
333
|
+
};
|
|
324
334
|
let messages: readonly AiMessage[] =
|
|
325
335
|
prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
|
|
326
336
|
let calls = 0;
|
|
@@ -369,68 +379,13 @@ async function run<
|
|
|
369
379
|
run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
|
|
370
380
|
});
|
|
371
381
|
if (result.text !== '') emit({ type: 'text', turn, text: result.text });
|
|
382
|
+
// Before a tool runs and before the answer is read (`X_LLM_REFUSED`, `X_LLM_TRUNCATED`).
|
|
372
383
|
assertAnswerable(result, name, base);
|
|
373
|
-
// Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
|
|
374
|
-
// arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
|
|
375
|
-
// reached its first byte — so running a tool on them is a side effect the model did not
|
|
376
|
-
// ask for, and validating them accepts an empty answer for any `output` of optional keys.
|
|
377
|
-
if (isTruncated(result.stopReason)) {
|
|
378
|
-
throw new LlmTruncatedError({
|
|
379
|
-
prompt: name,
|
|
380
|
-
maxTokens: base.maxTokens,
|
|
381
|
-
subject: SUBJECT,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
384
|
|
|
385
385
|
if (requested.length > 0) {
|
|
386
|
-
|
|
387
|
-
// model turn asked for, each entry is an ordinary `action` carrying its own policy and
|
|
388
|
-
// its own `rateLimit`, and a second ceiling here would be a throttle competing with
|
|
389
|
-
// those. A tool that calls a model still reserves on the same ledger chain, which
|
|
390
|
-
// debits before it checks, so the budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
|
|
391
|
-
// in the types or the docs ever said so.
|
|
392
|
-
//
|
|
393
|
-
// Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
|
|
394
|
-
// result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
|
|
395
|
-
// first cannot be paired with a slow tool's call.
|
|
396
|
-
throwIfAborted(args.ctx);
|
|
397
|
-
deadline.check();
|
|
398
|
-
for (const call of requested)
|
|
399
|
-
emit({ type: 'tool_call', turn, id: call.id, name: call.name });
|
|
400
|
-
const results: readonly LlmToolResult[] = await deadline.guard(
|
|
401
|
-
// Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
|
|
402
|
-
deadline.scope(() =>
|
|
403
|
-
Promise.all(
|
|
404
|
-
requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
|
|
405
|
-
),
|
|
406
|
-
),
|
|
407
|
-
);
|
|
386
|
+
messages = await toolTurn(tools, turn, result, requested, answers, messages);
|
|
408
387
|
calls += requested.length;
|
|
409
|
-
results.forEach((done, index) => {
|
|
410
|
-
const name = requested[index]?.name ?? '';
|
|
411
|
-
emit({
|
|
412
|
-
type: 'tool_result',
|
|
413
|
-
turn,
|
|
414
|
-
id: done.toolUseId,
|
|
415
|
-
name,
|
|
416
|
-
isError: done.isError === true,
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
emit({ type: 'turn', turn, maxTurns });
|
|
420
388
|
rejected = undefined;
|
|
421
|
-
// Before the next turn is bought: the same calls with the same results cannot lead
|
|
422
|
-
// anywhere a further turn would not also lead.
|
|
423
|
-
repeated(requested, results);
|
|
424
|
-
messages = [
|
|
425
|
-
...messages,
|
|
426
|
-
assistantTurn(result),
|
|
427
|
-
// Parallel tool use — one turn asking for a tool AND answering — is normal, and the
|
|
428
|
-
// answer is speculative: it was written before the result it asked for existed. So
|
|
429
|
-
// the loop continues, and the answer is REJECTED on the wire rather than dropped,
|
|
430
|
-
// because a replayed `respond` block with no `tool_result` is a 400 and the whole run
|
|
431
|
-
// becomes an `X_AI_PROVIDER_UNAVAILABLE`.
|
|
432
|
-
toolResultTurn(results, chars, answers, redact),
|
|
433
|
-
];
|
|
434
389
|
await landed(turn, messages, result.model);
|
|
435
390
|
continue;
|
|
436
391
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Single responsibility: where cross-request budget counters live — the `BudgetStore` contract
|
|
2
|
+
// (an atomic `take`), the per-process `memoryBudgetStore()`, and the guard a store's answer passes.
|
|
3
|
+
// Split from ./budget (the ledger) at its line ceiling.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What one `take` found. `spent` is the counter as the store read it, BEFORE this take — so a
|
|
7
|
+
* refusal can say how much was left.
|
|
8
|
+
*/
|
|
9
|
+
export interface BudgetTake {
|
|
10
|
+
readonly taken: boolean;
|
|
11
|
+
readonly spent: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
|
|
15
|
+
export interface BudgetStore {
|
|
16
|
+
spent(key: string): Promise<number> | number;
|
|
17
|
+
/** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
|
|
18
|
+
add(key: string, tokens: number): Promise<void> | void;
|
|
19
|
+
/**
|
|
20
|
+
* Add `tokens` to `key` only if the total stays at or under `limit`, in ONE atomic step — a
|
|
21
|
+
* Redis `EVAL`, a SQL `update … set spent = spent + $2 where key = $1 and spent + $2 <= $3`.
|
|
22
|
+
* Required, with no default built on `spent` + `add`: that pair is the read-then-write two
|
|
23
|
+
* concurrent requests both pass, which is the overspend this member exists to close.
|
|
24
|
+
*/
|
|
25
|
+
take(key: string, tokens: number, limit: number): Promise<BudgetTake> | BudgetTake;
|
|
26
|
+
reset(key?: string): Promise<void> | void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The per-process default. There is NO window: a counter lives until `reset()` or a restart, so an
|
|
31
|
+
* `actor` / `org` ceiling here is "per process lifetime". It holds one entry per caller that has
|
|
32
|
+
* spent under a DECLARED ceiling (none at all without one), and deliberately no eviction: dropping
|
|
33
|
+
* a counter hands that caller its whole ceiling back, which is the bypass the ceiling exists to
|
|
34
|
+
* stop. A window — per day, per month — is a shared store whose keys expire.
|
|
35
|
+
*/
|
|
36
|
+
export class MemoryBudgetStore implements BudgetStore {
|
|
37
|
+
private readonly counters = new Map<string, number>();
|
|
38
|
+
|
|
39
|
+
spent(key: string): number {
|
|
40
|
+
return this.counters.get(key) ?? 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A counter back at zero is deleted: absent and zero are the same answer, and an entry costs. */
|
|
44
|
+
add(key: string, tokens: number): void {
|
|
45
|
+
this.write(key, this.spent(key) + tokens);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Atomic because it is synchronous: nothing else runs between the read and the write. */
|
|
49
|
+
take(key: string, tokens: number, limit: number): BudgetTake {
|
|
50
|
+
const spent = this.spent(key);
|
|
51
|
+
if (spent + tokens > limit) return { taken: false, spent };
|
|
52
|
+
this.write(key, spent + tokens);
|
|
53
|
+
return { taken: true, spent };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How many counters are held — one per caller that has spent under a declared ceiling. */
|
|
57
|
+
size(): number {
|
|
58
|
+
return this.counters.size;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private write(key: string, total: number): void {
|
|
62
|
+
if (total === 0) this.counters.delete(key);
|
|
63
|
+
else this.counters.set(key, total);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reset(key?: string): void {
|
|
67
|
+
if (key === undefined) this.counters.clear();
|
|
68
|
+
else this.counters.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The one way to build the per-process store; the class is a type in the barrel only. */
|
|
73
|
+
export function memoryBudgetStore(): MemoryBudgetStore {
|
|
74
|
+
return new MemoryBudgetStore();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A store's `take` answer is app code's: read, never trusted. */
|
|
78
|
+
export function isBudgetTake(value: unknown): value is BudgetTake {
|
|
79
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
80
|
+
const record = value as Record<string, unknown>;
|
|
81
|
+
return typeof record['taken'] === 'boolean' && Number.isFinite(record['spent']);
|
|
82
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -14,11 +14,18 @@ import type { Actor } from '@ultimat3/core';
|
|
|
14
14
|
import { assert, asyncContext, finiteCount } from '@ultimat3/core';
|
|
15
15
|
import type { Money } from '@ultimat3/money';
|
|
16
16
|
import { assertSameCurrency, commonScale, exponentOf, minorAt } from '@ultimat3/money';
|
|
17
|
+
import type { BudgetStore } from './budget-store';
|
|
18
|
+
import { isBudgetTake, MemoryBudgetStore } from './budget-store';
|
|
17
19
|
import { compareMoney, costScale, ledgerUnits, roundUpCost } from './cost';
|
|
18
20
|
import { AiBudgetExceededError } from './errors';
|
|
19
21
|
import type { GenerateRequest, TokenUsage } from './provider';
|
|
20
22
|
import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from './provider';
|
|
21
23
|
|
|
24
|
+
// The store half lives in ./budget-store; named here too, so `./budget` stays the one import a
|
|
25
|
+
// ledger reader needs.
|
|
26
|
+
export type { BudgetStore, BudgetTake } from './budget-store';
|
|
27
|
+
export { MemoryBudgetStore, memoryBudgetStore } from './budget-store';
|
|
28
|
+
|
|
22
29
|
/** Ceilings. An omitted scope is unlimited — declare the ones that matter. */
|
|
23
30
|
export interface BudgetLimits {
|
|
24
31
|
/**
|
|
@@ -67,30 +74,6 @@ export function estimateSpend(request: GenerateRequest, marked = false): SpendEs
|
|
|
67
74
|
};
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
/**
|
|
71
|
-
* What one `take` found. `spent` is the counter as the store read it, BEFORE this take — so a
|
|
72
|
-
* refusal can say how much was left.
|
|
73
|
-
*/
|
|
74
|
-
export interface BudgetTake {
|
|
75
|
-
readonly taken: boolean;
|
|
76
|
-
readonly spent: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
|
|
80
|
-
export interface BudgetStore {
|
|
81
|
-
spent(key: string): Promise<number> | number;
|
|
82
|
-
/** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
|
|
83
|
-
add(key: string, tokens: number): Promise<void> | void;
|
|
84
|
-
/**
|
|
85
|
-
* Add `tokens` to `key` only if the total stays at or under `limit`, in ONE atomic step — a
|
|
86
|
-
* Redis `EVAL`, a SQL `update … set spent = spent + $2 where key = $1 and spent + $2 <= $3`.
|
|
87
|
-
* Required, with no default built on `spent` + `add`: that pair is the read-then-write two
|
|
88
|
-
* concurrent requests both pass, which is the overspend this member exists to close.
|
|
89
|
-
*/
|
|
90
|
-
take(key: string, tokens: number, limit: number): Promise<BudgetTake> | BudgetTake;
|
|
91
|
-
reset(key?: string): Promise<void> | void;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
77
|
/**
|
|
95
78
|
* The store keys a call's identity is counted under: the acting identity (kind and id — one window
|
|
96
79
|
* per actor, whichever org it acts in) and its org, when it has one. Derived, never taken from a
|
|
@@ -129,54 +112,6 @@ export interface BudgetReservation {
|
|
|
129
112
|
readonly tokens: number;
|
|
130
113
|
}
|
|
131
114
|
|
|
132
|
-
/**
|
|
133
|
-
* The per-process default. There is NO window: a counter lives until `reset()` or a restart, so an
|
|
134
|
-
* `actor` / `org` ceiling here is "per process lifetime". It holds one entry per caller that has
|
|
135
|
-
* spent under a DECLARED ceiling (none at all without one), and deliberately no eviction: dropping
|
|
136
|
-
* a counter hands that caller its whole ceiling back, which is the bypass the ceiling exists to
|
|
137
|
-
* stop. A window — per day, per month — is a shared store whose keys expire.
|
|
138
|
-
*/
|
|
139
|
-
export class MemoryBudgetStore implements BudgetStore {
|
|
140
|
-
private readonly counters = new Map<string, number>();
|
|
141
|
-
|
|
142
|
-
spent(key: string): number {
|
|
143
|
-
return this.counters.get(key) ?? 0;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** A counter back at zero is deleted: absent and zero are the same answer, and an entry costs. */
|
|
147
|
-
add(key: string, tokens: number): void {
|
|
148
|
-
this.write(key, this.spent(key) + tokens);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Atomic because it is synchronous: nothing else runs between the read and the write. */
|
|
152
|
-
take(key: string, tokens: number, limit: number): BudgetTake {
|
|
153
|
-
const spent = this.spent(key);
|
|
154
|
-
if (spent + tokens > limit) return { taken: false, spent };
|
|
155
|
-
this.write(key, spent + tokens);
|
|
156
|
-
return { taken: true, spent };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** How many counters are held — one per caller that has spent under a declared ceiling. */
|
|
160
|
-
size(): number {
|
|
161
|
-
return this.counters.size;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private write(key: string, total: number): void {
|
|
165
|
-
if (total === 0) this.counters.delete(key);
|
|
166
|
-
else this.counters.set(key, total);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
reset(key?: string): void {
|
|
170
|
-
if (key === undefined) this.counters.clear();
|
|
171
|
-
else this.counters.delete(key);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** The one way to build the per-process store; the class is a type in the barrel only. */
|
|
176
|
-
export function memoryBudgetStore(): MemoryBudgetStore {
|
|
177
|
-
return new MemoryBudgetStore();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
115
|
export interface BudgetLedgerInput {
|
|
181
116
|
readonly limits: BudgetLimits;
|
|
182
117
|
/** Stable identity keys. Omit a key to skip that scope even when a limit is set. */
|
|
@@ -461,12 +396,6 @@ export function screenedMoney(subject: string, key: string, money: Money): Money
|
|
|
461
396
|
return money;
|
|
462
397
|
}
|
|
463
398
|
|
|
464
|
-
function isBudgetTake(value: unknown): value is BudgetTake {
|
|
465
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
466
|
-
const record = value as Record<string, unknown>;
|
|
467
|
-
return typeof record['taken'] === 'boolean' && Number.isFinite(record['spent']);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
399
|
/** Spreadable single-key record, so an absent limit stays absent under exactOptionalPropertyTypes. */
|
|
471
400
|
function pick<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
|
472
401
|
return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
|
package/src/embeddings.ts
CHANGED
|
@@ -147,5 +147,5 @@ export function cosine(a: Float32Array, b: Float32Array): number {
|
|
|
147
147
|
normB += y * y;
|
|
148
148
|
}
|
|
149
149
|
const similarity = dot / Math.sqrt(normA * normB);
|
|
150
|
-
return
|
|
150
|
+
return Math.min(1, Math.max(-1, similarity));
|
|
151
151
|
}
|
package/src/errors.ts
CHANGED
|
@@ -72,7 +72,7 @@ export class AiGatewayMissingError extends UltimateError {
|
|
|
72
72
|
// The provider is the app's choice, so no vendor is the default (M12): the fix is the shape
|
|
73
73
|
// and the cause names what `provider` is — one of the peers, or the app's own.
|
|
74
74
|
cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured (provider: anthropicProvider({ models }) for the Anthropic Messages format, openAiProvider({ baseUrl, models }) for the OpenAI chat-completions format on any compatible server, or your own Provider)`,
|
|
75
|
-
fix: '
|
|
75
|
+
fix: 'export const runtime = { aiGateway: providerGateway({ providers: [provider] }) } # apps/<app>/runtime.ts — in a test, installAiGateway(gateway) from @ultimat3/ai/adapter',
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -187,16 +187,16 @@ export class AgentMaxTurnsError extends UltimateError {
|
|
|
187
187
|
* than filtered at the call, because a silently dropped tool is the worst of both: the
|
|
188
188
|
* declaration reads as if the model can call it, and the model is never offered it.
|
|
189
189
|
*
|
|
190
|
-
* `
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* `isAgentOffered` is the one predicate — an in-app agent sees every action an external MCP client
|
|
191
|
+
* sees, plus the ones declared `agents: true` — so the catalogue is still declared at the action,
|
|
192
|
+
* never filtered here.
|
|
193
193
|
*/
|
|
194
194
|
export class AgentToolUnexposedError extends UltimateError {
|
|
195
195
|
constructor(input: { agent: string; tools: readonly string[] }) {
|
|
196
196
|
super({
|
|
197
197
|
code: 'X_AGENT_TOOL_UNEXPOSED',
|
|
198
198
|
cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
|
|
199
|
-
fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: true } to the action named in cause to offer it to MCP clients too, or drop it from the agent's tools",
|
|
199
|
+
fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: false, agents: true } to the action named in cause for this app's agents alone, mcp: { expose: true } to offer it to MCP clients too, or drop it from the agent's tools",
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
202
|
}
|
package/src/eval-agent.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function agentPrompt(agent: AnyAction, evalName: string): Prompt {
|
|
|
66
66
|
assert(
|
|
67
67
|
prompt !== undefined,
|
|
68
68
|
`defineEval "${evalName}" names an agent whose prompt is not registered: an action agent() did not build, a named() twin, or a prompt reset since`,
|
|
69
|
-
`
|
|
69
|
+
`export the agent({ … }) from a module defineApi({ actions }) registers, and pass that action to defineEval({ agent })`,
|
|
70
70
|
);
|
|
71
71
|
return prompt;
|
|
72
72
|
}
|