@ultimat3/ai 30.8.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/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
- interface AiRuntime {
53
- readonly gateway: Gateway;
54
- readonly embedder: Embedder;
55
- readonly semanticCache: (scope: string) => SemanticCache;
56
- readonly redact: Redactor;
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 runtime: AiRuntime | undefined;
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
- export function configureAi(input: AiRuntimeInput): void {
63
- runtime = {
64
- gateway: input.gateway,
65
- embedder: input.embedder ?? hashEmbedder(),
66
- semanticCache: input.semanticCache ?? (() => memorySemanticCache()),
67
- redact: input.redact ?? noRedaction,
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
- // A new runtime means a new embedder and a new gateway; vectors from the old one are not
70
- // comparable to vectors from the new one, and a stale hit would answer the wrong question.
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
- if (runtime === undefined) throw new AiGatewayMissingError({ prompt });
112
- return runtime.gateway;
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 ?? runtime?.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 runtime?.embedder ?? hashEmbedder();
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 runtime?.redact ?? noRedaction;
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
- runtime = undefined;
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) ? (n < 0 ? 0 : n > 1 ? 1 : n) : n > 0 ? 1 : 0;
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
- isMcpExposed,
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?: { readonly expose?: boolean; readonly description?: string };
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 (`isMcpExposed`,
198
- * core's one predicate — an in-app agent and an external one see the same catalogue of ACTIONS), or
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 || isMcpExposed(tool.mcp);
209
+ }): boolean => tool.inProcess === true || isAgentOffered(tool.mcp);
205
210
 
206
211
  /**
207
- * Every exposed action as a tool definition, in stable name order. The gateway and MCP ask
208
- * `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are
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
  }