@ultimat3/ai 23.0.0 → 24.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 +34 -6
- package/README.md +45 -12
- package/package.json +11 -10
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +14 -13
- package/src/budget.ts +138 -46
- package/src/embeddings.ts +10 -11
- package/src/error-body.ts +38 -3
- package/src/errors.ts +2 -0
- package/src/eval-baseline.ts +5 -1
- package/src/gateway.ts +33 -21
- package/src/hive.ts +10 -2
- package/src/index.ts +4 -2
- package/src/llm-cache.ts +3 -2
- package/src/llm.ts +13 -55
- package/src/openai-wire.ts +7 -2
- package/src/pg-vector.ts +7 -4
- package/src/provider.ts +9 -7
- package/src/rag.ts +10 -3
- package/src/remote-embedder.ts +5 -36
- package/src/respond.ts +86 -0
- package/src/runtime.ts +5 -0
- package/src/scorers.ts +13 -2
- package/src/tools.ts +30 -19
- package/src/vector-scope.ts +44 -2
- package/src/vector.ts +16 -6
package/src/remote-embedder.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { finiteCount, readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
10
10
|
import type { Embedder } from './embeddings';
|
|
11
11
|
import { normalize } from './embeddings';
|
|
12
|
+
import { detailOf, withoutKey } from './error-body';
|
|
12
13
|
import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
|
|
13
14
|
import type { AiFetch } from './fetch-seam';
|
|
14
15
|
|
|
@@ -16,7 +17,6 @@ const API_KEY_ENV = 'EMBEDDINGS_API_KEY';
|
|
|
16
17
|
const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
|
|
17
18
|
/** Providers cap a batch around 128 inputs; 96 leaves headroom for long texts. */
|
|
18
19
|
const DEFAULT_BATCH_SIZE = 96;
|
|
19
|
-
const DETAIL_LIMIT = 300;
|
|
20
20
|
/**
|
|
21
21
|
* A hosted endpoint is a third party and `baseUrl` is app config, so neither its latency nor its
|
|
22
22
|
* response size is ours to assume. 30s is longer than any healthy embedding batch and shorter than
|
|
@@ -124,7 +124,7 @@ export class RemoteEmbedder implements Embedder {
|
|
|
124
124
|
// and the endpoint is app config, so the rejection is a value this package did not build —
|
|
125
125
|
// and `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, whose throw escapes this very
|
|
126
126
|
// `catch` and replaces a coded refusal with an uncoded crash.
|
|
127
|
-
detail: `${renderThrowable(error)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
|
|
127
|
+
detail: `${withoutKey(renderThrowable(error), apiKey)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
|
|
128
128
|
envVar: API_KEY_ENV,
|
|
129
129
|
});
|
|
130
130
|
}
|
|
@@ -132,7 +132,9 @@ export class RemoteEmbedder implements Embedder {
|
|
|
132
132
|
throw new AiTransportError({
|
|
133
133
|
provider: this.name,
|
|
134
134
|
status: response.status,
|
|
135
|
-
|
|
135
|
+
// The shared reader and the shared scrub: a proxy echoing the bearer header into its 4xx
|
|
136
|
+
// body put the key into an error that reaches a log, a span and a problem document.
|
|
137
|
+
detail: withoutKey(await detailOf(response), apiKey),
|
|
136
138
|
envVar: API_KEY_ENV,
|
|
137
139
|
});
|
|
138
140
|
}
|
|
@@ -218,36 +220,3 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
218
220
|
function countOf(data: unknown): string {
|
|
219
221
|
return Array.isArray(data) ? String(data.length) : 'no data array';
|
|
220
222
|
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The start of an error body, and never more. `response.text()` read it WHOLE — outside
|
|
224
|
-
* `maxResponseBytes`, which bounds a success only — so a provider streaming an endless 5xx body
|
|
225
|
-
* held the call and its memory forever. Capped through the same counting reader, and decoded from
|
|
226
|
-
* the bytes it kept: `over` still has a detail worth showing.
|
|
227
|
-
*/
|
|
228
|
-
async function detailOf(body: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
229
|
-
if (body === null) return '';
|
|
230
|
-
const reader = body.getReader();
|
|
231
|
-
const kept: Uint8Array[] = [];
|
|
232
|
-
let total = 0;
|
|
233
|
-
try {
|
|
234
|
-
while (total < DETAIL_LIMIT) {
|
|
235
|
-
const { done, value } = await reader.read();
|
|
236
|
-
if (done) break;
|
|
237
|
-
kept.push(value);
|
|
238
|
-
total += value.byteLength;
|
|
239
|
-
}
|
|
240
|
-
} catch {
|
|
241
|
-
// A body that fails mid-read still has whatever arrived; the status is the error's substance.
|
|
242
|
-
} finally {
|
|
243
|
-
await reader.cancel().catch(() => undefined);
|
|
244
|
-
reader.releaseLock();
|
|
245
|
-
}
|
|
246
|
-
const bytes = new Uint8Array(total);
|
|
247
|
-
let offset = 0;
|
|
248
|
-
for (const chunk of kept) {
|
|
249
|
-
bytes.set(chunk, offset);
|
|
250
|
-
offset += chunk.byteLength;
|
|
251
|
-
}
|
|
252
|
-
return new TextDecoder().decode(bytes).slice(0, DETAIL_LIMIT);
|
|
253
|
-
}
|
package/src/respond.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The `respond` tool an `llm()` / `agent()` answers through, and the reader that gets the answer
|
|
2
|
+
// back out of a turn: the tool call's arguments, or the prose parsed as JSON. One file so the
|
|
3
|
+
// envelope a non-object `output` is wrapped in and its unwrapping cannot drift apart.
|
|
4
|
+
|
|
5
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
6
|
+
import { toWireSchema } from '@ultimat3/schema';
|
|
7
|
+
import type { GenerateResult } from './provider';
|
|
8
|
+
import type { LlmTool } from './tools';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tool the model answers through. One name, so the reader never has to guess — and shared
|
|
12
|
+
* with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
|
|
13
|
+
* answer from a tool call.
|
|
14
|
+
*/
|
|
15
|
+
export const RESPOND = 'respond';
|
|
16
|
+
|
|
17
|
+
/** The one property a non-object `output` travels under. */
|
|
18
|
+
const ENVELOPE = 'value';
|
|
19
|
+
|
|
20
|
+
/** The tool for one `output`, and the reader that undoes whatever the tool did to it. */
|
|
21
|
+
export interface Respond {
|
|
22
|
+
readonly tool: LlmTool;
|
|
23
|
+
/** The answer in `result`, unwrapped — or `undefined` when there is none to validate. */
|
|
24
|
+
read(result: GenerateResult): unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The output schema as the only tool the model may answer through — the spec's "structured
|
|
29
|
+
* output drives tool use". `toWireSchema` is the projection an MCP client is served, so a model
|
|
30
|
+
* and an agent are shown one shape, and a schema it cannot express throws HERE, at declaration
|
|
31
|
+
* time.
|
|
32
|
+
*
|
|
33
|
+
* A tool's `input_schema` must be an OBJECT, so any other `output` (`t.string`, `t.number`, an
|
|
34
|
+
* array) is wrapped in `{ value }` and unwrapped on the way back — never refused: `.stream()` is
|
|
35
|
+
* documented for `output: t.string`, and the same declaration must work called plainly.
|
|
36
|
+
*/
|
|
37
|
+
export function respondFor(output: StandardSchemaV1): Respond {
|
|
38
|
+
const schema = toWireSchema(output);
|
|
39
|
+
const wrapped = schema.type !== 'object';
|
|
40
|
+
return {
|
|
41
|
+
tool: {
|
|
42
|
+
name: RESPOND,
|
|
43
|
+
description: 'Return the result. Call this exactly once; do not answer in prose.',
|
|
44
|
+
input_schema: wrapped
|
|
45
|
+
? {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: { [ENVELOPE]: schema },
|
|
48
|
+
required: [ENVELOPE],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
}
|
|
51
|
+
: schema,
|
|
52
|
+
strict: true,
|
|
53
|
+
},
|
|
54
|
+
read(result) {
|
|
55
|
+
const call = result.toolCalls.find((c) => c.name === RESPOND);
|
|
56
|
+
if (call !== undefined) return wrapped ? call.input[ENVELOPE] : call.input;
|
|
57
|
+
// A model that answers in prose is a schema failure, not a crash, so it flows into the
|
|
58
|
+
// repair turn. For a wrapped output the prose itself may BE the answer (`t.string`), the
|
|
59
|
+
// rule `.stream()` already applies.
|
|
60
|
+
const parsed = parseJsonish(result.text);
|
|
61
|
+
return parsed === undefined && wrapped && result.text !== '' ? result.text : parsed;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FENCE = '```';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The JSON in a prose answer: the first fenced block when there is a closed one, else the whole
|
|
70
|
+
* text. Two `indexOf` scans, never a backtracking pattern: the regex this replaced re-scanned the
|
|
71
|
+
* tail from every whitespace position after an unterminated fence — quadratic on model output.
|
|
72
|
+
*/
|
|
73
|
+
export function parseJsonish(text: string): unknown {
|
|
74
|
+
const open = text.indexOf(FENCE);
|
|
75
|
+
const close = open === -1 ? -1 : text.indexOf(FENCE, open + FENCE.length);
|
|
76
|
+
let body = text;
|
|
77
|
+
if (close !== -1) {
|
|
78
|
+
body = text.slice(open + FENCE.length, close);
|
|
79
|
+
if (body.startsWith('json')) body = body.slice('json'.length);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(body.trim());
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -77,6 +77,11 @@ export function aiGateway(prompt: string): Gateway {
|
|
|
77
77
|
return runtime.gateway;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** The installed gateway, or `undefined` — for a caller that may run without one (`hive()`). */
|
|
81
|
+
export function installedGateway(): Gateway | undefined {
|
|
82
|
+
return runtime?.gateway;
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
export function aiEmbedder(): Embedder {
|
|
81
86
|
return runtime?.embedder ?? new HashEmbedder();
|
|
82
87
|
}
|
package/src/scorers.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// is a model call, which means it is itself a measuring instrument that can drift — so its
|
|
5
5
|
// prompt is a versioned artifact and its hash is part of the scorer's name.
|
|
6
6
|
|
|
7
|
-
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
import { assert, finiteCount, finiteOption } from '@ultimat3/core';
|
|
8
8
|
import type { Gateway } from './gateway';
|
|
9
9
|
import type { Prompt } from './prompt';
|
|
10
10
|
|
|
@@ -81,8 +81,17 @@ export function jsonSchemaValid(required: readonly string[]): Scorer {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Graded numeric closeness — 1 at exact, 0 at or beyond `tolerance`. `tolerance: 0` is exact match.
|
|
86
|
+
* Screened at declaration: a `NaN` scored every answer `NaN`, a negative one every answer 0.
|
|
87
|
+
*/
|
|
85
88
|
export function numericTolerance(tolerance: number): Scorer {
|
|
89
|
+
finiteOption('numericTolerance', 'tolerance', tolerance);
|
|
90
|
+
assert(
|
|
91
|
+
tolerance >= 0,
|
|
92
|
+
`numericTolerance tolerance is ${String(tolerance)}: a distance cannot be negative, so every answer would score 0`,
|
|
93
|
+
'numericTolerance(0) # 0 is exact match; a positive tolerance grades closeness',
|
|
94
|
+
);
|
|
86
95
|
return {
|
|
87
96
|
name: 'numeric-tolerance',
|
|
88
97
|
score: ({ output, expected }) => {
|
|
@@ -90,6 +99,8 @@ export function numericTolerance(tolerance: number): Scorer {
|
|
|
90
99
|
const want = Number.parseFloat((expected ?? '').trim());
|
|
91
100
|
if (Number.isNaN(got) || Number.isNaN(want)) return 0;
|
|
92
101
|
const delta = Math.abs(got - want);
|
|
102
|
+
// Exact first: at `tolerance: 0`, `0 >= 0` read an exact match as out of range.
|
|
103
|
+
if (delta === 0) return 1;
|
|
93
104
|
return delta >= tolerance ? 0 : 1 - delta / tolerance;
|
|
94
105
|
},
|
|
95
106
|
};
|
package/src/tools.ts
CHANGED
|
@@ -20,21 +20,16 @@
|
|
|
20
20
|
import type { AnyAction } from '@ultimat3/action';
|
|
21
21
|
import { actionName, invoke, isAction } from '@ultimat3/action';
|
|
22
22
|
import type { Actor } from '@ultimat3/core';
|
|
23
|
-
import { isMcpExposed, stringField } from '@ultimat3/core';
|
|
24
|
-
import {
|
|
23
|
+
import { hasPublicCause, isMcpExposed, isUltimateError, stringField } from '@ultimat3/core';
|
|
24
|
+
import { statusFor } from '@ultimat3/http';
|
|
25
|
+
import type { WireJsonSchema } from '@ultimat3/schema';
|
|
26
|
+
import { toWireSchema } from '@ultimat3/schema';
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
readonly required?: readonly string[];
|
|
32
|
-
/** A schema, not just a flag: `@ultimat3/schema` emits one for open records. */
|
|
33
|
-
readonly additionalProperties?: boolean | JsonSchema;
|
|
34
|
-
readonly items?: JsonSchema;
|
|
35
|
-
readonly enum?: readonly (string | number | boolean | null)[];
|
|
36
|
-
readonly default?: unknown;
|
|
37
|
-
}
|
|
28
|
+
/**
|
|
29
|
+
* The JSON Schema subset the framework emits for tool arguments — `@ultimat3/schema`'s
|
|
30
|
+
* `WireJsonSchema`, the one document `.tool()`, `tools/list` and the model are all handed.
|
|
31
|
+
*/
|
|
32
|
+
export type JsonSchema = WireJsonSchema;
|
|
38
33
|
|
|
39
34
|
/** Anthropic Messages API tool definition shape (`tools[]` on a request). */
|
|
40
35
|
export interface LlmTool {
|
|
@@ -91,10 +86,8 @@ export type AgentTool = AnyAction | ProjectableAction;
|
|
|
91
86
|
/**
|
|
92
87
|
* Adapt whatever the author listed. The same shape @ultimat3/mcp's `asProjectable` produces, from
|
|
93
88
|
* the same `invoke` — an in-app agent and an external MCP client end at one execution path, so one
|
|
94
|
-
* policy decides both.
|
|
95
|
-
*
|
|
96
|
-
* that server's arg validator will hold a call to; this one publishes the tool schema the Messages
|
|
97
|
-
* API reads).
|
|
89
|
+
* policy decides both. The execution adapter is not shared code and cannot be — `mcp` is this
|
|
90
|
+
* package's own tier — but the schema is: both publish `@ultimat3/schema`'s `toWireSchema`.
|
|
98
91
|
*
|
|
99
92
|
* `isAction` is structural against @ultimat3/action's PRIVATE declaration store, so a look-alike
|
|
100
93
|
* carrying `kind: 'action'` cannot take the first branch — it falls through as the already
|
|
@@ -109,7 +102,7 @@ export function asProjectableAction(listed: AgentTool): ProjectableAction {
|
|
|
109
102
|
name: actionName(listed),
|
|
110
103
|
...(mcp === undefined ? {} : { mcp }),
|
|
111
104
|
...(mcp?.description === undefined ? {} : { description: mcp.description }),
|
|
112
|
-
inputJsonSchema:
|
|
105
|
+
inputJsonSchema: toWireSchema(listed.input),
|
|
113
106
|
// The actor rides in on the options and `invoke` swaps it inside the one execution path —
|
|
114
107
|
// the action's own `policy` still decides, and its `input:` still parses what the model sent,
|
|
115
108
|
// which is what drops a `{ actor: 'admin' }` the model invented before any handler sees it.
|
|
@@ -197,15 +190,33 @@ function resultOf(toolUseId: string, output: unknown): LlmToolResult {
|
|
|
197
190
|
return { toolUseId, content: text ?? 'null' };
|
|
198
191
|
}
|
|
199
192
|
|
|
193
|
+
/**
|
|
194
|
+
* What the model reads in place of a cause it may not see. Fixed, so nothing of the throw rides on
|
|
195
|
+
* it: the provider keeps every request it is sent, and a cause that reached it cannot be recalled.
|
|
196
|
+
*/
|
|
197
|
+
export const HIDDEN_TOOL_CAUSE =
|
|
198
|
+
'the tool failed inside the server; the details are withheld from this conversation and are in the server logs';
|
|
199
|
+
|
|
200
200
|
/**
|
|
201
201
|
* The thrown value, read STRUCTURALLY and totally. `stringField` from `@ultimat3/core`, never
|
|
202
202
|
* `typeof e.code === 'string'`: the value is whatever an app's handler, its driver or its SDK
|
|
203
203
|
* threw, so each read is a getter call or a `Proxy` trap — and it runs inside the catch block
|
|
204
204
|
* that has nothing left to answer the model with if the probe itself raises.
|
|
205
|
+
*
|
|
206
|
+
* A 5xx code's cause is shown only when core's `hasPublicCause` allows it — the rule a production
|
|
207
|
+
* problem document follows (`@ultimat3/http`'s `toProblem`). Hidden, the developer's `fix` goes with
|
|
208
|
+
* it (a driver writes it from the statement it failed on); a `callerFix` an `UltimateError` built in
|
|
209
|
+
* this process is authored for a remote reader and stays.
|
|
205
210
|
*/
|
|
206
211
|
function describeFailure(error: unknown): string {
|
|
207
212
|
const code = stringField(error, 'code');
|
|
208
213
|
if (code === undefined) return 'tool failed';
|
|
214
|
+
if (statusFor(code) >= 500 && !hasPublicCause(code)) {
|
|
215
|
+
const callerFix = isUltimateError(error) ? stringField(error, 'callerFix') : undefined;
|
|
216
|
+
return callerFix === undefined
|
|
217
|
+
? `${code}: ${HIDDEN_TOOL_CAUSE}`
|
|
218
|
+
: `${code}: ${HIDDEN_TOOL_CAUSE} (fix: ${callerFix})`;
|
|
219
|
+
}
|
|
209
220
|
const cause = stringField(error, 'cause') ?? 'unknown';
|
|
210
221
|
const fix = stringField(error, 'fix') ?? '';
|
|
211
222
|
return fix === '' ? `${code}: ${cause}` : `${code}: ${cause} (fix: ${fix})`;
|
package/src/vector-scope.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// production is a leak nobody finds. Deriving a scope may only ever TIGHTEN it: a scope that
|
|
5
5
|
// could be widened from a call site is not a scope, it is a hint.
|
|
6
6
|
|
|
7
|
+
import { tryUseContext, UltimateError } from '@ultimat3/core';
|
|
7
8
|
import { VectorScopeWidenedError } from './errors';
|
|
8
9
|
|
|
9
10
|
export interface VectorScope {
|
|
@@ -14,10 +15,22 @@ export interface VectorScope {
|
|
|
14
15
|
* deny — a row missing the key is invisible, and an empty list matches nothing at all.
|
|
15
16
|
*/
|
|
16
17
|
readonly allow?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Every tenant, ON PURPOSE — `UNSCOPED`'s mark, and the only way a read with no tenant bound
|
|
20
|
+
* runs inside a request acting for an org. Meaningless once a tenant is bound, so dropped then.
|
|
21
|
+
*/
|
|
22
|
+
readonly crossTenant?: true | undefined;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Every tenant, every row, named on purpose: the backfill and migration path. Pass it as a
|
|
27
|
+
* store's `scope` (or `.scoped(UNSCOPED)`) where a cross-tenant read is the point. A store opened
|
|
28
|
+
* with no scope binds no tenant either, but reading it inside an org's request is refused.
|
|
29
|
+
*/
|
|
30
|
+
export const UNSCOPED: VectorScope = Object.freeze({ crossTenant: true });
|
|
31
|
+
|
|
32
|
+
/** A store's scope when none was given: no tenant, and no cross-tenant opt-in. */
|
|
33
|
+
export const UNBOUND: VectorScope = Object.freeze({});
|
|
21
34
|
|
|
22
35
|
/** The tenant column value a row carries when its store had no tenant bound. */
|
|
23
36
|
export const NO_TENANT = '';
|
|
@@ -33,12 +46,41 @@ export function tenantOf(scope: VectorScope): string {
|
|
|
33
46
|
export function narrowScope(store: string, base: VectorScope, next: VectorScope): VectorScope {
|
|
34
47
|
const tenant = narrowTenant(store, base.tenant, next.tenant);
|
|
35
48
|
const allow = narrowAllow(base.allow, next.allow);
|
|
49
|
+
const crossTenant =
|
|
50
|
+
tenant === undefined && (base.crossTenant === true || next.crossTenant === true);
|
|
36
51
|
return {
|
|
37
52
|
...(tenant === undefined ? {} : { tenant }),
|
|
38
53
|
...(allow === undefined ? {} : { allow }),
|
|
54
|
+
...(crossTenant ? { crossTenant: true as const } : {}),
|
|
39
55
|
};
|
|
40
56
|
}
|
|
41
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Refuse a read that would search every tenant inside a request acting for one. Entities derive
|
|
60
|
+
* the tenant from the ambient actor; a vector store is bound by `.scoped({ tenant })`, and a
|
|
61
|
+
* forgotten call answered with every org's rows. Outside a request, or for an actor with no org,
|
|
62
|
+
* there is no tenant to leak across — a single-tenant app and a job's system actor read as before.
|
|
63
|
+
*/
|
|
64
|
+
export function assertTenantRead(store: string, scope: VectorScope): void {
|
|
65
|
+
if (scope.tenant !== undefined || scope.crossTenant === true) return;
|
|
66
|
+
if (tryUseContext()?.actor.orgId === undefined) return;
|
|
67
|
+
throw new VectorUnscopedError({ store });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `X_VECTOR_UNSCOPED`. Its code and title stay in `errors.ts`, beside every other `X_VECTOR_*`. */
|
|
71
|
+
export class VectorUnscopedError extends UltimateError {
|
|
72
|
+
constructor(input: { store: string }) {
|
|
73
|
+
super({
|
|
74
|
+
code: 'X_VECTOR_UNSCOPED',
|
|
75
|
+
cause:
|
|
76
|
+
`vector store "${input.store}" was read with no tenant bound, inside a request acting ` +
|
|
77
|
+
'for an org — the read would answer with every tenant’s rows. A deliberate cross-tenant ' +
|
|
78
|
+
'read (a backfill, a migration) opens the store with scope: UNSCOPED',
|
|
79
|
+
fix: 'vectorStore.scoped({ tenant: ctx.actor.orgId })',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
42
84
|
function narrowTenant(
|
|
43
85
|
store: string,
|
|
44
86
|
base: string | undefined,
|
package/src/vector.ts
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
12
12
|
import { cosine, tokenize } from './embeddings';
|
|
13
13
|
import { VectorDimMismatchError } from './errors';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
assertTenantRead,
|
|
16
|
+
NO_TENANT,
|
|
17
|
+
narrowScope,
|
|
18
|
+
scopeAdmits,
|
|
19
|
+
UNBOUND,
|
|
20
|
+
type VectorScope,
|
|
21
|
+
} from './vector-scope';
|
|
15
22
|
|
|
16
23
|
export interface VectorRecord {
|
|
17
24
|
readonly id: string;
|
|
@@ -44,7 +51,7 @@ export interface HybridSearchInput {
|
|
|
44
51
|
export interface VectorStore {
|
|
45
52
|
readonly name: string;
|
|
46
53
|
readonly dimension: number;
|
|
47
|
-
/** The tenant + policy envelope every statement carries. `
|
|
54
|
+
/** The tenant + policy envelope every statement carries. `UNBOUND` on a fresh store. */
|
|
48
55
|
readonly scope: VectorScope;
|
|
49
56
|
/** A view of the same rows through a NARROWER envelope. It can only ever tighten. */
|
|
50
57
|
scoped(scope: VectorScope): VectorStore;
|
|
@@ -56,10 +63,10 @@ export interface VectorStore {
|
|
|
56
63
|
/**
|
|
57
64
|
* Delete every row `filter` matches EXCEPT the ids in `keep`, inside this store's scope. What a
|
|
58
65
|
* re-index needs: `indexDocument` upserts a document's new chunks, then prunes the ones the
|
|
59
|
-
* shorter text no longer produced.
|
|
60
|
-
*
|
|
66
|
+
* shorter text no longer produced. Required: an optional `prune` was a re-index that silently
|
|
67
|
+
* left the old tail retrievable on any store that skipped it.
|
|
61
68
|
*/
|
|
62
|
-
prune
|
|
69
|
+
prune(filter: MetadataFilter, keep: readonly string[]): Promise<void>;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export interface MemoryVectorStoreInput {
|
|
@@ -101,7 +108,7 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
101
108
|
this.input = input;
|
|
102
109
|
this.name = input.name ?? 'memory';
|
|
103
110
|
this.dimension = input.dimension;
|
|
104
|
-
this.scope = input.scope ??
|
|
111
|
+
this.scope = input.scope ?? UNBOUND;
|
|
105
112
|
this.records = input.records ?? new Map<string, StoredRecord>();
|
|
106
113
|
// Screened, not clamped: a `NaN` here makes every BM25 score `NaN`, `hit.score > 0` reads
|
|
107
114
|
// false for every document, and `searchText` answers an empty list — zero work reported as a
|
|
@@ -136,6 +143,7 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
136
143
|
k: number,
|
|
137
144
|
filter?: MetadataFilter,
|
|
138
145
|
): Promise<readonly SearchHit[]> {
|
|
146
|
+
assertTenantRead(this.name, this.scope);
|
|
139
147
|
this.assertDimension(vector.length);
|
|
140
148
|
// `k` carries no default, so `??` never sees it and nothing else does either: `slice(0, NaN)`
|
|
141
149
|
// is `[]`, which is a search that answers "no matches" for every query and reports success.
|
|
@@ -151,6 +159,7 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
151
159
|
k: number,
|
|
152
160
|
filter?: MetadataFilter,
|
|
153
161
|
): Promise<readonly SearchHit[]> {
|
|
162
|
+
assertTenantRead(this.name, this.scope);
|
|
154
163
|
const width = finiteCount(SUBJECT, 'k', k);
|
|
155
164
|
const candidates = this.candidates(filter);
|
|
156
165
|
if (candidates.length === 0) return [];
|
|
@@ -182,6 +191,7 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
182
191
|
* by exact term match beats one that merely leads a flat vector ranking.
|
|
183
192
|
*/
|
|
184
193
|
async hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]> {
|
|
194
|
+
assertTenantRead(this.name, this.scope);
|
|
185
195
|
// Three bounds, none of which `Math.max` screens — it PROPAGATES a `NaN`. A `NaN` `k` collapses
|
|
186
196
|
// both candidate widths and the final slice to `[]`; a `NaN` `rrfK` makes every fused score
|
|
187
197
|
// `NaN`, and the ranking this method exists to produce becomes whatever order the sort left.
|