@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.
@@ -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
- detail: await detailOf(response.body),
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
- /** Graded numeric closeness — 1 at exact, 0 at or beyond `tolerance`. */
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 { toMcpInputSchema } from '@ultimat3/schema';
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
- /** The JSON Schema subset the framework emits for tool arguments. */
27
- export interface JsonSchema {
28
- readonly type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null';
29
- readonly description?: string;
30
- readonly properties?: Readonly<Record<string, JsonSchema>>;
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. It is not shared code and cannot be: `mcp` is this package's own tier, and
95
- * the two projections narrow the schema differently on purpose (`toWireSchema` publishes only what
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: toMcpInputSchema(listed.input),
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})`;
@@ -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
- /** The store as constructed: every tenant, every row. The backfill and migration path. */
20
- export const UNSCOPED: VectorScope = Object.freeze({});
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 { NO_TENANT, narrowScope, scopeAdmits, UNSCOPED, type VectorScope } from './vector-scope';
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. `UNSCOPED` on a fresh store. */
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. Optional so a hand-written store still satisfies the type;
60
- * without it a re-index cannot clean up, and says so in `indexDocument`'s doc.
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?(filter: MetadataFilter, keep: readonly string[]): Promise<void>;
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 ?? UNSCOPED;
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.