@ultimat3/ai 30.9.0 → 31.1.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
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The ambient AI runtime an `llm()` action calls into: the gateway that serves every model
|
|
2
|
-
// call, the embedder the semantic cache keys on, and one semantic cache per declared scope.
|
|
2
|
+
// call, the embedder the semantic cache keys on, and one semantic cache per declared scope. All
|
|
3
|
+
// three are adapter ports, filled from `apps/<app>/runtime.ts` through `@ultimat3/ai/adapter`.
|
|
3
4
|
//
|
|
4
5
|
// Ambient rather than injected, for the same reason the budget ledger is: an `llm()`
|
|
5
6
|
// declaration is evaluated at module scope, long before a provider, an API key or a request
|
|
@@ -10,21 +11,19 @@
|
|
|
10
11
|
import type { SemanticCache } from '@ultimat3/cache';
|
|
11
12
|
import { memorySemanticCache } from '@ultimat3/cache';
|
|
12
13
|
import { asyncContext, cachedFormatter } from '@ultimat3/core';
|
|
14
|
+
import { type AdapterSlot, adapterSlot } from '@ultimat3/core/adapter';
|
|
13
15
|
import type { Embedder } from './embeddings';
|
|
14
16
|
import { hashEmbedder } from './embeddings';
|
|
15
17
|
import { AiGatewayMissingError } from './errors';
|
|
16
18
|
import type { Gateway } from './gateway';
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The AI runtime's configuration — the redactor, and nothing that selects a backend. The gateway,
|
|
22
|
+
* the embedder and the semantic-cache factory are ports with one way in each: `aiGateway`,
|
|
23
|
+
* `embedder` and `semanticCache` in `apps/<app>/runtime.ts`, installed by the boot through
|
|
24
|
+
* `@ultimat3/ai/adapter`. `gateway`, `embedder` and `semanticCache` left this input in 31.0.0.
|
|
25
|
+
*/
|
|
18
26
|
export interface AiRuntimeInput {
|
|
19
|
-
readonly gateway: Gateway;
|
|
20
|
-
/**
|
|
21
|
-
* Keys the semantic cache. Defaults to `HashEmbedder`: deterministic, offline, and lexical
|
|
22
|
-
* rather than semantic — at the thresholds an LLM cache runs at that is a near-exact-match
|
|
23
|
-
* cache, which is the safe way to be wrong. Install a real embedder to get paraphrase hits.
|
|
24
|
-
*/
|
|
25
|
-
readonly embedder?: Embedder;
|
|
26
|
-
/** One cache per scope key. Defaults to the in-memory driver; pgvector in production. */
|
|
27
|
-
readonly semanticCache?: (scope: string) => SemanticCache;
|
|
28
27
|
/**
|
|
29
28
|
* The last thing that runs over a prompt before it leaves the process. Declared here, in the
|
|
30
29
|
* one place the framework already owns, because `vars()` is the one declared place a model call
|
|
@@ -33,7 +32,7 @@ export interface AiRuntimeInput {
|
|
|
33
32
|
*
|
|
34
33
|
* WHAT to remove is the app's decision, not the framework's: a PII classifier is a model choice
|
|
35
34
|
* and ships in no framework (axiom 8). Ultimate ships the seam, the span attribute
|
|
36
|
-
* (`llm.redacted`) and the one rule it can enforce structurally — a `Secret` reaching `vars()`
|
|
35
|
+
* (`ultimate.llm.redacted`) and the one rule it can enforce structurally — a `Secret` reaching `vars()`
|
|
37
36
|
* is `X_AI_PROMPT_SECRET`, whether or not a redactor is installed.
|
|
38
37
|
*/
|
|
39
38
|
readonly redact?: Redactor;
|
|
@@ -46,28 +45,73 @@ export interface AiRuntimeInput {
|
|
|
46
45
|
*/
|
|
47
46
|
export type Redactor = (text: string) => string;
|
|
48
47
|
|
|
49
|
-
/** No app redactor installed. Named rather than inline so `llm.redacted` has one thing to mean. */
|
|
48
|
+
/** No app redactor installed. Named rather than inline so `ultimate.llm.redacted` has one thing to mean. */
|
|
50
49
|
const noRedaction: Redactor = (text) => text;
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
51
|
+
/**
|
|
52
|
+
* The `semanticCache` port: one cache per scope key. A FACTORY, because scopes are separate cache
|
|
53
|
+
* instances (`semanticCacheFor`). Default: a `memorySemanticCache()` per scope, per process.
|
|
54
|
+
*/
|
|
55
|
+
export type SemanticCacheFactory = (scope: string) => SemanticCache;
|
|
58
56
|
|
|
59
|
-
let
|
|
57
|
+
let redactor: Redactor = noRedaction;
|
|
58
|
+
/** The `aiGateway` port's ONE slot, written only by `installAiGateway`. */
|
|
59
|
+
const gatewaySlot = /* @__PURE__ */ adapterSlot<Gateway | undefined>(() => undefined);
|
|
60
|
+
/**
|
|
61
|
+
* The `embedder` port. Default `hashEmbedder()`: deterministic, offline and lexical rather than
|
|
62
|
+
* semantic — at the thresholds an LLM cache runs at that is a near-exact-match cache, which is the
|
|
63
|
+
* safe way to be wrong. Install a real embedder (`remoteEmbedder`) to get paraphrase hits.
|
|
64
|
+
*/
|
|
65
|
+
const embedderSlot = /* @__PURE__ */ adapterSlot<Embedder>(() => fallbackEmbedder());
|
|
66
|
+
/** The `semanticCache` port. Default the in-memory driver; pgvector in production. */
|
|
67
|
+
const semanticCacheSlot = /* @__PURE__ */ adapterSlot<SemanticCacheFactory>(() => memoryPerScope);
|
|
68
|
+
const memoryPerScope: SemanticCacheFactory = () => memorySemanticCache();
|
|
69
|
+
let hashed: Embedder | undefined;
|
|
70
|
+
/** One default embedder per process, built on first use rather than at import. */
|
|
71
|
+
const fallbackEmbedder = (): Embedder => {
|
|
72
|
+
hashed ??= hashEmbedder();
|
|
73
|
+
return hashed;
|
|
74
|
+
};
|
|
60
75
|
const caches = new Map<string, SemanticCache>();
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Install into a slot whose value every cached `SemanticCache` was built against, and clear the
|
|
79
|
+
* caches on the install and on the release alike: vectors from two embedders are not comparable,
|
|
80
|
+
* and a test's double must never be served what the real backend answered (and back).
|
|
81
|
+
*/
|
|
82
|
+
function installClearing<T>(slot: AdapterSlot<T>, value: T): () => void {
|
|
83
|
+
const release = slot.install(value);
|
|
84
|
+
caches.clear();
|
|
85
|
+
return () => {
|
|
86
|
+
release();
|
|
87
|
+
caches.clear();
|
|
68
88
|
};
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The boot's install of the `aiGateway` port, from `@ultimat3/ai/adapter` only. Answers the release
|
|
93
|
+
* that removes THIS install and no other (core's `adapterSlot`), in any order, once.
|
|
94
|
+
*/
|
|
95
|
+
export function installAiGateway(gateway: Gateway): () => void {
|
|
96
|
+
// A cached answer is one gateway's: kept across a swap, a test's double would be served what
|
|
97
|
+
// the real gateway answered (and back).
|
|
98
|
+
return installClearing(gatewaySlot, gateway);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The boot's install of the `embedder` port, from `@ultimat3/ai/adapter` only. Answers its release. */
|
|
102
|
+
export function installAiEmbedder(embedder: Embedder): () => void {
|
|
103
|
+
return installClearing(embedderSlot, embedder);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The boot's install of the `semanticCache` port, from `@ultimat3/ai/adapter` only. */
|
|
107
|
+
export function installSemanticCache(factory: SemanticCacheFactory): () => void {
|
|
108
|
+
return installClearing(semanticCacheSlot, factory);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Replace the runtime's configuration; an omitted field takes its default. Clears the caches. */
|
|
112
|
+
export function configureAi(input: AiRuntimeInput = {}): void {
|
|
113
|
+
redactor = input.redact ?? noRedaction;
|
|
114
|
+
// What a test calls between cases to start from empty caches; harmless anywhere else.
|
|
71
115
|
caches.clear();
|
|
72
116
|
}
|
|
73
117
|
|
|
@@ -108,22 +152,24 @@ export function currentEvalScope(): EvalScope | undefined {
|
|
|
108
152
|
export function aiGateway(prompt: string): Gateway {
|
|
109
153
|
const scoped = evalScopes.get()?.gateway;
|
|
110
154
|
if (scoped !== undefined) return scoped;
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
const installed = gatewaySlot.current();
|
|
156
|
+
if (installed === undefined) throw new AiGatewayMissingError({ prompt });
|
|
157
|
+
return installed;
|
|
113
158
|
}
|
|
114
159
|
|
|
115
160
|
/** The installed gateway, or `undefined` — for a caller that may run without one (`hive()`). */
|
|
116
161
|
export function installedGateway(): Gateway | undefined {
|
|
117
|
-
return evalScopes.get()?.gateway ??
|
|
162
|
+
return evalScopes.get()?.gateway ?? gatewaySlot.current();
|
|
118
163
|
}
|
|
119
164
|
|
|
165
|
+
/** The installed embedder (`embedder` port), or the process's hash embedder. */
|
|
120
166
|
export function aiEmbedder(): Embedder {
|
|
121
|
-
return
|
|
167
|
+
return embedderSlot.current();
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
/** The installed redactor, or the identity. Never absent, so the call site has no branch. */
|
|
125
171
|
export function aiRedactor(): Redactor {
|
|
126
|
-
return
|
|
172
|
+
return redactor;
|
|
127
173
|
}
|
|
128
174
|
|
|
129
175
|
/**
|
|
@@ -139,15 +185,14 @@ export function aiRedactor(): Redactor {
|
|
|
139
185
|
* no entries at all and the memory one loses a cache.
|
|
140
186
|
*/
|
|
141
187
|
export function semanticCacheFor(scope: string): SemanticCache {
|
|
142
|
-
return cachedFormatter(
|
|
143
|
-
caches,
|
|
144
|
-
scope,
|
|
145
|
-
() => runtime?.semanticCache(scope) ?? memorySemanticCache(),
|
|
146
|
-
);
|
|
188
|
+
return cachedFormatter(caches, scope, () => semanticCacheSlot.current()(scope));
|
|
147
189
|
}
|
|
148
190
|
|
|
149
191
|
/** Test-only reset. Module-level state otherwise leaks between test files. */
|
|
150
192
|
export function resetAiRuntime(): void {
|
|
151
|
-
|
|
193
|
+
redactor = noRedaction;
|
|
194
|
+
gatewaySlot.reset();
|
|
195
|
+
embedderSlot.reset();
|
|
196
|
+
semanticCacheSlot.reset();
|
|
152
197
|
caches.clear();
|
|
153
198
|
}
|
package/src/sampling.ts
CHANGED
|
@@ -88,7 +88,7 @@ export function ignoredSampling(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* The `llm.sampling.ignored` span attribute, set when a run ENDS — answered or failed — never at
|
|
91
|
+
* The `ultimate.llm.sampling.ignored` span attribute, set when a run ENDS — answered or failed — never at
|
|
92
92
|
* its start. The catalogue is the app's, and an app may register it when its gateway is first used
|
|
93
93
|
* (a gateway built lazily from the env): read at the top of `agent()` / `llm()`, before the gateway
|
|
94
94
|
* was asked anything, the row was not there yet and a REPORT refused the call `X_AI_MODEL_UNKNOWN`.
|
|
@@ -98,15 +98,15 @@ export function ignoredSamplingAttribute(
|
|
|
98
98
|
model: ModelId,
|
|
99
99
|
request: Sampling,
|
|
100
100
|
wires: () => readonly SamplingWire[] | undefined,
|
|
101
|
-
): Record<'llm.sampling.ignored', string> | Record<string, never> {
|
|
101
|
+
): Record<'ultimate.llm.sampling.ignored', string> | Record<string, never> {
|
|
102
102
|
if (request.temperature === undefined && request.seed === undefined) {
|
|
103
|
-
return { 'llm.sampling.ignored': '' };
|
|
103
|
+
return { 'ultimate.llm.sampling.ignored': '' };
|
|
104
104
|
}
|
|
105
105
|
if (!isModelRegistered(model)) return {};
|
|
106
106
|
// A report never fails the call it describes: an installed gateway may throw from
|
|
107
107
|
// `samplingWires`, and the attribute is then left out rather than replacing the call's result.
|
|
108
108
|
try {
|
|
109
|
-
return { 'llm.sampling.ignored': ignoredSampling(model, request, wires()).join(',') };
|
|
109
|
+
return { 'ultimate.llm.sampling.ignored': ignoredSampling(model, request, wires()).join(',') };
|
|
110
110
|
} catch {
|
|
111
111
|
return {};
|
|
112
112
|
}
|
package/src/scorers.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface Scorer {
|
|
|
52
52
|
* `"score": null` for a `NaN`, which `parseBaseline` then refuses as `X_EVAL_BASELINE_INVALID`.
|
|
53
53
|
*/
|
|
54
54
|
export const clampScore = (n: number): number =>
|
|
55
|
-
Number.isFinite(n) ? (
|
|
55
|
+
Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : n > 0 ? 1 : 0;
|
|
56
56
|
|
|
57
57
|
/** Exact match after trimming. The strictest and cheapest scorer; prefer it when it fits. */
|
|
58
58
|
export const exact: Scorer = {
|
package/src/tools.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '@ultimat3/action';
|
|
29
29
|
import type { Actor } from '@ultimat3/core';
|
|
30
30
|
import {
|
|
31
|
-
|
|
31
|
+
isAgentOffered,
|
|
32
32
|
isUltimateError,
|
|
33
33
|
stringField,
|
|
34
34
|
toolDescription,
|
|
@@ -82,7 +82,11 @@ export interface LlmToolResult {
|
|
|
82
82
|
export interface ProjectableAction {
|
|
83
83
|
readonly name: string;
|
|
84
84
|
readonly description?: string;
|
|
85
|
-
readonly mcp?: {
|
|
85
|
+
readonly mcp?: {
|
|
86
|
+
readonly expose?: boolean;
|
|
87
|
+
readonly agents?: boolean;
|
|
88
|
+
readonly description?: string;
|
|
89
|
+
};
|
|
86
90
|
readonly inputJsonSchema?: JsonSchema;
|
|
87
91
|
/**
|
|
88
92
|
* The tool runs IN PROCESS — the app's own code, called by its own agent — and is not the
|
|
@@ -194,19 +198,19 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
/**
|
|
197
|
-
* Whether an agent may be OFFERED this tool: an action an MCP surface exposes
|
|
198
|
-
* core's one predicate — an in-app agent
|
|
201
|
+
* Whether an agent may be OFFERED this tool: an action an MCP surface exposes or one declared
|
|
202
|
+
* `agents: true` (`isAgentOffered`, core's one predicate — an in-app agent sees every exposed
|
|
203
|
+
* action, plus the ones offered here and published nowhere, such as `mcpTools()`'s), or
|
|
199
204
|
* a tool that is this process's alone (`inProcessTool()`), which no surface but the agent sees.
|
|
200
205
|
*/
|
|
201
206
|
export const isOfferedTool = (tool: {
|
|
202
207
|
readonly mcp?: ProjectableAction['mcp'];
|
|
203
208
|
readonly inProcess?: boolean;
|
|
204
|
-
}): boolean => tool.inProcess === true ||
|
|
209
|
+
}): boolean => tool.inProcess === true || isAgentOffered(tool.mcp);
|
|
205
210
|
|
|
206
211
|
/**
|
|
207
|
-
* Every
|
|
208
|
-
*
|
|
209
|
-
* offered exactly the same tools.
|
|
212
|
+
* Every offered action as a tool definition, in stable name order — `isOfferedTool`, so an in-app
|
|
213
|
+
* agent sees every action an external MCP client sees, plus those declared for agents only.
|
|
210
214
|
*/
|
|
211
215
|
export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] {
|
|
212
216
|
return actions
|
package/src/vector.ts
CHANGED
|
@@ -141,8 +141,10 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
141
141
|
|
|
142
142
|
async upsert(records: readonly VectorRecord[]): Promise<void> {
|
|
143
143
|
const tenant = this.scope.tenant ?? NO_VECTOR_TENANT;
|
|
144
|
+
// Every width checked before any row is written, as `PostgresVectorStore.upsert` does: checked
|
|
145
|
+
// per record, a refused batch had already written the rows ahead of the bad one.
|
|
146
|
+
for (const record of records) this.assertDimension(record.vector.length);
|
|
144
147
|
for (const record of records) {
|
|
145
|
-
this.assertDimension(record.vector.length);
|
|
146
148
|
this.records.set(storageKey(tenant, record.id), { ...record, tenant });
|
|
147
149
|
}
|
|
148
150
|
}
|