@ultimat3/ai 27.8.1 → 27.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/agent-def.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Single responsibility: what an `agent()` DECLARATION is — its options, and the one event its
|
|
2
|
+
// observer is handed. Types only; ./agent owns the loop that reads them.
|
|
3
|
+
|
|
4
|
+
import type { ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AgentSystem } from './agent-system';
|
|
9
|
+
import type { AgentRunUsage } from './agent-usage';
|
|
10
|
+
import type { LlmBudget } from './llm';
|
|
11
|
+
import type { ModelId } from './models';
|
|
12
|
+
import type { Prompt, PromptVars } from './prompt';
|
|
13
|
+
import type { PromptCacheOption } from './prompt-cache';
|
|
14
|
+
import type { StopReason, TokenUsage } from './provider';
|
|
15
|
+
import type { AgentTool } from './tools';
|
|
16
|
+
|
|
17
|
+
export interface AgentBudget extends LlmBudget {
|
|
18
|
+
/**
|
|
19
|
+
* Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
|
|
20
|
+
* a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
|
|
21
|
+
*/
|
|
22
|
+
readonly tokensPerRun?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
|
|
26
|
+
readonly input: InferOutput<TInput>;
|
|
27
|
+
readonly ctx: Ctx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgentDef<
|
|
31
|
+
TInput extends StandardSchemaV1,
|
|
32
|
+
TOutput extends StandardSchemaV1,
|
|
33
|
+
V extends PromptVars,
|
|
34
|
+
> {
|
|
35
|
+
readonly model?: ModelId;
|
|
36
|
+
readonly input: TInput;
|
|
37
|
+
readonly output: TOutput;
|
|
38
|
+
readonly prompt: Prompt<V>;
|
|
39
|
+
/** Same contract as `llm()`: the one declared place a run loads data. */
|
|
40
|
+
vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
|
|
41
|
+
/**
|
|
42
|
+
* System text loaded at RUN time, beside the prompt's static `system`: a knowledge-base index,
|
|
43
|
+
* today's date. Redacted like the prompt. Not part of the prompt's hash, exactly as `vars` is not.
|
|
44
|
+
*/
|
|
45
|
+
system?(args: AgentVarsArgs<TInput>): AgentSystem | Promise<AgentSystem>;
|
|
46
|
+
/**
|
|
47
|
+
* Provider prompt caching. Default `'auto'`: every turn's request asks the provider to cache its
|
|
48
|
+
* prefix (the stable system block, then the transcript up to its tail), so turn N+1 reads turn
|
|
49
|
+
* N's prefix at the cache rate instead of paying input for it again. `'off'` sends no mark.
|
|
50
|
+
* `{ session }` is `'auto'` plus a key for hosts that route by one. `PROMPT_CACHE_RULES` decides
|
|
51
|
+
* what each wire is sent; a host that caches implicitly is sent nothing either way.
|
|
52
|
+
*/
|
|
53
|
+
readonly promptCache?: PromptCacheOption<AgentVarsArgs<TInput>>;
|
|
54
|
+
/**
|
|
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.
|
|
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;
|
|
68
|
+
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
69
|
+
readonly maxTurns?: number;
|
|
70
|
+
readonly maxToolResultChars?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Consecutive turns that may make the SAME tool calls and read the SAME results. Reaching it is
|
|
73
|
+
* `X_AGENT_TOOL_LOOP`. Default 3.
|
|
74
|
+
*/
|
|
75
|
+
readonly maxRepeatedToolCalls?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Wall-clock ceiling for the whole run, in milliseconds. Passing it aborts the model call in
|
|
78
|
+
* flight and is `X_AGENT_DEADLINE`. No default: a timeout nobody declared is a lost answer.
|
|
79
|
+
*/
|
|
80
|
+
readonly deadlineMs?: number;
|
|
81
|
+
readonly budget?: AgentBudget;
|
|
82
|
+
readonly policy: ActionPolicy;
|
|
83
|
+
readonly mcp?: ActionMcp;
|
|
84
|
+
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
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;
|
|
95
|
+
/**
|
|
96
|
+
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
97
|
+
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
98
|
+
* it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
|
|
99
|
+
* hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
|
|
100
|
+
*
|
|
101
|
+
* Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
|
|
102
|
+
* actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
|
|
103
|
+
* run's own path, and an observer that silently stopped working would be indistinguishable from
|
|
104
|
+
* one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
|
|
105
|
+
* declares no hook is still readable in a trace.
|
|
106
|
+
*
|
|
107
|
+
* NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
|
|
108
|
+
* one turn deep by construction; this is per TURN.
|
|
109
|
+
*/
|
|
110
|
+
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
|
|
114
|
+
export interface AgentTurn {
|
|
115
|
+
/** 1-based, so `turn === maxTurns` is the last one this run will take. */
|
|
116
|
+
readonly turn: number;
|
|
117
|
+
readonly maxTurns: number;
|
|
118
|
+
readonly model: ModelId;
|
|
119
|
+
/** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
|
|
120
|
+
readonly toolCalls: readonly string[];
|
|
121
|
+
readonly stopReason: StopReason;
|
|
122
|
+
readonly usage: TokenUsage;
|
|
123
|
+
/** This turn's cost alone, integer minor units, rounded up — never the run's running total. */
|
|
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[];
|
|
132
|
+
/** The running total, this turn included — on the last turn, the whole run. */
|
|
133
|
+
readonly run: AgentRunUsage;
|
|
134
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
// one job and that catalogue is at its ceiling — the split `hive-errors.ts` made. The codes, their
|
|
3
|
+
// titles and the single `registerErrorCodes` call stay in ./errors.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The loop asked for the same tool calls and read the same results, turn after turn.
|
|
9
|
+
*
|
|
10
|
+
* Call AND result, never the call alone: polling one tool with one argument is legitimate while
|
|
11
|
+
* what comes back keeps changing, and it stops being progress the moment it does not. A turn that
|
|
12
|
+
* repeats both can only be followed by a turn that repeats both, so every further turn buys the
|
|
13
|
+
* same non-answer over a transcript that is one turn longer.
|
|
14
|
+
*/
|
|
15
|
+
export class AgentToolLoopError extends UltimateError {
|
|
16
|
+
constructor(input: {
|
|
17
|
+
agent: string;
|
|
18
|
+
/** Laps of the cycle. */
|
|
19
|
+
repeats: number;
|
|
20
|
+
/** Turns in one lap: 1 is the same turn over and over, 2 is A,B,A,B. */
|
|
21
|
+
cycle: number;
|
|
22
|
+
tools: readonly string[];
|
|
23
|
+
}) {
|
|
24
|
+
super({
|
|
25
|
+
code: 'X_AGENT_TOOL_LOOP',
|
|
26
|
+
cause:
|
|
27
|
+
input.cycle === 1
|
|
28
|
+
? `agent "${input.agent}" made the same tool calls (${input.tools.join(', ')}) and read the same results ${input.repeats} turns in a row`
|
|
29
|
+
: `agent "${input.agent}" went round the same ${input.cycle} tool turns (${input.tools.join(', ')}), reading the same results, ${input.repeats} times in a row`,
|
|
30
|
+
fix: 'maxRepeatedToolCalls: 5 # on the agent, ONLY for a tool that is polled and answers identically while it waits — otherwise tell the template what to do when a tool result does not change, then bump its version',
|
|
31
|
+
meta: { agent: input.agent, repeats: input.repeats, cycle: input.cycle, tools: input.tools },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The run was still going when its declared `deadlineMs` elapsed. Distinct from `X_ABORTED` — the
|
|
38
|
+
* caller did not leave, the declaration ran out — and from `X_AGENT_MAX_TURNS`, because the fix is
|
|
39
|
+
* about wall clock: a slow tool or a slow model, not a loop with no exit.
|
|
40
|
+
*/
|
|
41
|
+
export class AgentDeadlineError extends UltimateError {
|
|
42
|
+
constructor(input: { agent: string; deadlineMs: number }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_AGENT_DEADLINE',
|
|
45
|
+
cause: `agent "${input.agent}" was still running after its ${input.deadlineMs}ms deadline; whatever was in flight (setup, a model call or a tool) was signalled to stop, and no further turn or tool was started`,
|
|
46
|
+
fix: 'deadlineMs: 300_000 # on the agent, only once a trace shows the run converging — otherwise lower maxTurns, or shorten the slow tool the agent.turn span events name',
|
|
47
|
+
meta: { agent: input.agent, deadlineMs: input.deadlineMs },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Single responsibility: the two guards that let an `agent()` run with nobody watching — the
|
|
2
|
+
// repeated-turn detector and the per-run deadline. Apart from ./agent so the loop reads as a loop.
|
|
3
|
+
|
|
4
|
+
import { fingerprint, withChildContext } from '@ultimat3/core';
|
|
5
|
+
import { AgentDeadlineError, AgentToolLoopError } from './agent-errors';
|
|
6
|
+
import type { LlmToolCall, LlmToolResult } from './tools';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Consecutive identical turns an agent may take when the declaration omits a number. Three: one
|
|
10
|
+
* repeat is a model re-checking, two is a model that has not read the answer, and nothing a third
|
|
11
|
+
* identical turn returns can make the fourth differ.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_MAX_REPEATED_TOOL_CALLS = 3;
|
|
14
|
+
|
|
15
|
+
/** The longest cycle looked for: A,B,C,A,B,C. A longer one is `maxTurns`' to stop. */
|
|
16
|
+
const MAX_CYCLE = 3;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Called once per TOOL turn, with what the model asked for and what it was told. Throws
|
|
20
|
+
* `X_AGENT_TOOL_LOOP` when the most recent tool turns are one cycle of up to `MAX_CYCLE` turns
|
|
21
|
+
* gone round `limit` times — the same turn `limit` times, or A,B,A,B… — where "the same" is the
|
|
22
|
+
* calls, their arguments, the results AND whether each failed. A changed result is progress and
|
|
23
|
+
* breaks the cycle. A fingerprint, not the text: one result can be megabytes before truncation.
|
|
24
|
+
*
|
|
25
|
+
* A repair turn (a wrong-shaped answer; no tool ran) is not a tool turn, so it neither counts nor
|
|
26
|
+
* resets: the correction is about the answer's shape and tells the model nothing new about a tool.
|
|
27
|
+
*/
|
|
28
|
+
export function toolLoopGuard(
|
|
29
|
+
agent: string,
|
|
30
|
+
limit: number,
|
|
31
|
+
): (calls: readonly LlmToolCall[], results: readonly LlmToolResult[]) => void {
|
|
32
|
+
const window = MAX_CYCLE * limit;
|
|
33
|
+
let recent: readonly { readonly shape: string; readonly tools: readonly string[] }[] = [];
|
|
34
|
+
return (calls, results) => {
|
|
35
|
+
const shape = fingerprint({
|
|
36
|
+
calls: calls.map((call) => [call.name, call.input]),
|
|
37
|
+
results: results.map((result) => [result.content, result.isError === true]),
|
|
38
|
+
});
|
|
39
|
+
recent = [...recent, { shape, tools: calls.map((call) => call.name) }].slice(-window);
|
|
40
|
+
for (let cycle = 1; cycle <= MAX_CYCLE; cycle += 1) {
|
|
41
|
+
const laps = recent.slice(-cycle * limit);
|
|
42
|
+
if (laps.length < cycle * limit) break;
|
|
43
|
+
const repeats = (turn: (typeof laps)[number], index: number): boolean =>
|
|
44
|
+
index < cycle || isSameTurn(turn.shape, laps[index - cycle]?.shape);
|
|
45
|
+
if (!laps.every(repeats)) continue;
|
|
46
|
+
const tools = [...new Set(laps.slice(0, cycle).flatMap((turn) => turn.tools))];
|
|
47
|
+
throw new AgentToolLoopError({ agent, repeats: limit, cycle, tools });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isSameTurn = (one: string, other: string | undefined): boolean => one === other;
|
|
53
|
+
|
|
54
|
+
export interface RunDeadline {
|
|
55
|
+
/** The caller's signal and the deadline as one — what every provider call is handed. */
|
|
56
|
+
readonly signal: AbortSignal;
|
|
57
|
+
/** Throws `X_AGENT_DEADLINE` once the deadline has passed. Called before each turn and batch. */
|
|
58
|
+
check(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Await `work`, but no longer than the deadline, and report a failure the deadline caused AS the
|
|
61
|
+
* deadline: the aborted socket surfaces as a transport error, which would otherwise read as the
|
|
62
|
+
* provider's fault. Work that outlives the race is left to finish — a tool has no signal to hear.
|
|
63
|
+
*/
|
|
64
|
+
guard<T>(work: Promise<T>): Promise<T>;
|
|
65
|
+
/**
|
|
66
|
+
* Run `fn` with the deadline on the ambient `ctx.signal`, so a tool — an `action()` reading its
|
|
67
|
+
* own `ctx.signal`, a `fetch(url, { signal })` inside it — hears the deadline and can stop. A
|
|
68
|
+
* tool that never reads the signal still runs to its own end after the run has been refused.
|
|
69
|
+
*/
|
|
70
|
+
scope<T>(fn: () => T): T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `ms` undefined is no deadline: the caller's signal, and two no-ops. */
|
|
74
|
+
export function runDeadline(
|
|
75
|
+
agent: string,
|
|
76
|
+
ms: number | undefined,
|
|
77
|
+
caller: AbortSignal,
|
|
78
|
+
): RunDeadline {
|
|
79
|
+
if (ms === undefined) {
|
|
80
|
+
return { signal: caller, check: () => {}, guard: (work) => work, scope: (fn) => fn() };
|
|
81
|
+
}
|
|
82
|
+
const timer = AbortSignal.timeout(ms);
|
|
83
|
+
const expired = (): AgentDeadlineError => new AgentDeadlineError({ agent, deadlineMs: ms });
|
|
84
|
+
// The caller leaving first stays `X_ABORTED`: it is the truer account of why the run stopped.
|
|
85
|
+
const byDeadline = (): boolean => timer.aborted && !caller.aborted;
|
|
86
|
+
return {
|
|
87
|
+
signal: AbortSignal.any([caller, timer]),
|
|
88
|
+
scope: (fn) => withChildContext({ signal: timer }, fn),
|
|
89
|
+
check: () => {
|
|
90
|
+
if (byDeadline()) throw expired();
|
|
91
|
+
},
|
|
92
|
+
guard: async (work) => {
|
|
93
|
+
if (byDeadline()) {
|
|
94
|
+
// Abandoned before it was awaited; its own rejection must not go unhandled.
|
|
95
|
+
work.catch(() => {});
|
|
96
|
+
throw expired();
|
|
97
|
+
}
|
|
98
|
+
let stop = (): void => {};
|
|
99
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
100
|
+
stop = () => reject(expired());
|
|
101
|
+
timer.addEventListener('abort', stop, { once: true });
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
return await Promise.race([work, timeout]);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
work.catch(() => {});
|
|
107
|
+
if (byDeadline()) throw expired();
|
|
108
|
+
throw error;
|
|
109
|
+
} finally {
|
|
110
|
+
timer.removeEventListener('abort', stop);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Single responsibility: the system prompt ONE agent run sends — the prompt's static text and what
|
|
2
|
+
// the declaration's `system()` loaded, composed into the stable and per-run parts a cache keys on.
|
|
3
|
+
|
|
4
|
+
import type { SystemPrompt } from './prompt-cache';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What `system()` adds to the prompt's own `system`. `stable` joins it in the block every run
|
|
8
|
+
* shares — it must be BYTE-identical run to run (sorted, undated), or each run pays a full cache
|
|
9
|
+
* write for it. `run` is a second block after it, for whatever differs.
|
|
10
|
+
*/
|
|
11
|
+
export interface AgentSystem {
|
|
12
|
+
readonly stable?: string;
|
|
13
|
+
readonly run?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The system prompt one run sends: the prompt's static text and `system()`'s stable part as ONE
|
|
18
|
+
* block (the half every run shares), the per-run part after it. A plain string when there is no
|
|
19
|
+
* per-run part — the shape every request had before the parts existed.
|
|
20
|
+
*/
|
|
21
|
+
export function systemOf(
|
|
22
|
+
fixed: string | undefined,
|
|
23
|
+
extra: AgentSystem,
|
|
24
|
+
redact: (text: string) => string,
|
|
25
|
+
): SystemPrompt | undefined {
|
|
26
|
+
const stable = [fixed ?? '', extra.stable ?? '']
|
|
27
|
+
.filter((part) => part !== '')
|
|
28
|
+
.map(redact)
|
|
29
|
+
.join('\n\n');
|
|
30
|
+
const run = extra.run === undefined || extra.run === '' ? '' : redact(extra.run);
|
|
31
|
+
if (run !== '') return { stable, run };
|
|
32
|
+
return fixed === undefined && stable === '' ? undefined : stable;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether two composed prompts are the same text — what `llm.redacted` compares. */
|
|
36
|
+
export const sameSystem = (a: SystemPrompt | undefined, b: SystemPrompt | undefined): boolean =>
|
|
37
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Single responsibility: a run's running token and money total, across its turns and its
|
|
2
|
+
// continuations.
|
|
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';
|
|
8
|
+
import type { GenerateResult, TokenUsage } from './provider';
|
|
9
|
+
|
|
10
|
+
/** The run so far, this turn included. */
|
|
11
|
+
export interface AgentRunUsage {
|
|
12
|
+
readonly usage: TokenUsage;
|
|
13
|
+
/** In the currency's minor units, rounded up ONCE over the whole run — never a sum of turns. */
|
|
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));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A run's total across its continuations: what the handle already held plus what this call used,
|
|
47
|
+
* priced once from the sum.
|
|
48
|
+
*/
|
|
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),
|
|
62
|
+
};
|
|
63
|
+
}
|