@ultimat3/ai 2.0.0 → 4.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.
@@ -0,0 +1,90 @@
1
+ // The bounded, order-preserving, cancellation-linked worker pool a hive fans out through.
2
+ //
3
+ // Apart from `hive.ts` because it is a different job with a different failure mode: that file owns
4
+ // the declaration and the budget scope, this one owns "how many at once, in what order, and what
5
+ // happens to the siblings when one throws". Nothing here knows what a model is.
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import { isThrownError, isUltimateError, stringField, withChildContext } from '@ultimat3/core';
9
+ import type { HiveMember, HiveMemberError } from './hive-result';
10
+ import { SKIPPED_ABORTED, SKIPPED_NO_INPUT } from './hive-result';
11
+
12
+ export interface PoolInput<I, O> {
13
+ readonly inputs: readonly I[];
14
+ readonly width: number;
15
+ readonly ctx: Ctx;
16
+ readonly onMemberError: HiveMemberError;
17
+ /** One member run. The caller supplies it already bound, so this file never sees an action. */
18
+ member(payload: I): Promise<O>;
19
+ }
20
+
21
+ /**
22
+ * A bounded pool of `width` workers over one shared cursor. Results land BY INDEX, so the answer is
23
+ * in split order however the members interleave — `Promise.all` over a mapped array would give the
24
+ * same ordering but no ceiling, and a settle-ordered push would give neither.
25
+ *
26
+ * The controller is linked to `ctx.signal` in both directions that matter: the caller going away
27
+ * aborts every member, and `onMemberError: 'abort'` aborts the siblings without touching the
28
+ * caller's own signal. Each member runs under `withChildContext({ signal })`, which carries the
29
+ * actor forward untouched — the hive never names an identity.
30
+ */
31
+ export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly HiveMember<O>[]> {
32
+ const { inputs, width, ctx } = input;
33
+ const members = new Array<HiveMember<O>>(inputs.length);
34
+ const controller = new AbortController();
35
+ const relay = (): void => controller.abort();
36
+ if (ctx.signal.aborted) controller.abort();
37
+ ctx.signal.addEventListener('abort', relay, { once: true });
38
+
39
+ let cursor = 0;
40
+ const worker = async (): Promise<void> => {
41
+ for (;;) {
42
+ const index = cursor;
43
+ cursor += 1;
44
+ if (index >= inputs.length) return;
45
+ const payload = inputs[index];
46
+ // Every index is claimed by exactly one worker and assigned exactly once, so the array has
47
+ // no holes for a caller to trip over — `skipped` is a recorded outcome, never an absence.
48
+ // Two reasons, because they are two facts: the run stopped, or the split had nothing here.
49
+ if (controller.signal.aborted || payload === undefined) {
50
+ const reason = controller.signal.aborted ? SKIPPED_ABORTED : SKIPPED_NO_INPUT;
51
+ members[index] = { status: 'skipped', index, reason };
52
+ continue;
53
+ }
54
+ try {
55
+ const value = await withChildContext({ signal: controller.signal }, () =>
56
+ input.member(payload),
57
+ );
58
+ members[index] = { status: 'ok', index, value };
59
+ } catch (error) {
60
+ members[index] = { status: 'failed', index, ...failureOf(error) };
61
+ if (input.onMemberError === 'abort') controller.abort();
62
+ }
63
+ }
64
+ };
65
+
66
+ try {
67
+ await Promise.all(Array.from({ length: width }, worker));
68
+ } finally {
69
+ ctx.signal.removeEventListener('abort', relay);
70
+ }
71
+ return members;
72
+ }
73
+
74
+ /**
75
+ * What a member threw, as two data fields — never as an error's `cause:`, which is why the thrown
76
+ * value is read structurally and never interpolated. A foreign throw gets `'unknown'` rather than
77
+ * an invented `X_` code: a code nothing declares is a code no `x errors explain` can answer.
78
+ */
79
+ function failureOf(error: unknown): { readonly code: string; readonly reason: string } {
80
+ if (isUltimateError(error)) return { code: error.code, reason: error.cause };
81
+ // `isThrownError` and `stringField`, never `error instanceof Error` and `.message`: a member
82
+ // is an app's action, so the value is one the framework did not build — `instanceof` runs a
83
+ // `Proxy`'s `getPrototypeOf` trap and `.message` is a getter call. A throw HERE would take the
84
+ // whole hive down with it, which is the one outcome the three arms exist to prevent.
85
+ const message = stringField(error, 'message');
86
+ if (isThrownError(error) && message !== undefined && message !== '') {
87
+ return { code: 'unknown', reason: message };
88
+ }
89
+ return { code: 'unknown', reason: 'the member threw a value that is not an Error' };
90
+ }
@@ -0,0 +1,96 @@
1
+ // What a hive run answers, as a TYPE and as a SCHEMA built from the member's own `output`.
2
+ //
3
+ // It is a schema and not a plain interface because `hive()` returns an `action`, and an action's
4
+ // `output:` is what drives `validateOutput`, the OpenAPI response body, the typed client, the MCP
5
+ // tool and the manifest row. A hand-written interface would give the type and none of the six
6
+ // projections — the whole reason a hive is a factory over `action()` rather than a helper.
7
+
8
+ import type { Money } from '@ultimat3/money';
9
+ import type { AnySchema, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
+ import { t } from '@ultimat3/schema';
11
+
12
+ /**
13
+ * One member's outcome. THREE arms, not two: a member that ran and threw and a member that never
14
+ * ran at all are different facts, and the second one is what an aborted sibling is. Collapsing
15
+ * them would make "the hive stopped early" indistinguishable from "every remaining item is bad
16
+ * data", which is the difference between retrying the tail and fixing the source.
17
+ *
18
+ * `index` is the position in `split`'s output on every arm, so a caller can join a result back to
19
+ * the row it came from without depending on array position surviving a filter.
20
+ */
21
+ export type HiveMember<O> =
22
+ | { readonly status: 'ok'; readonly index: number; readonly value: O }
23
+ | {
24
+ readonly status: 'failed';
25
+ readonly index: number;
26
+ /** The `UltimateError` code the member threw, or `'unknown'` for a foreign throw. */
27
+ readonly code: string;
28
+ readonly reason: string;
29
+ }
30
+ | { readonly status: 'skipped'; readonly index: number; readonly reason: string };
31
+
32
+ export interface HiveResult<O> {
33
+ /** In SPLIT order, always — never completion order, and never with the failures filtered out. */
34
+ readonly members: readonly HiveMember<O>[];
35
+ readonly ok: number;
36
+ readonly failed: number;
37
+ /**
38
+ * Published beside `ok` and `failed` rather than left to be derived: three arms and two counters
39
+ * means `members.length - ok - failed`, and a caller who writes that once writes it wrong once.
40
+ */
41
+ readonly skipped: number;
42
+ /** Tokens the whole run debited, every member counted, from the hive's own derived ledger. */
43
+ readonly tokens: number;
44
+ readonly cost: Money;
45
+ }
46
+
47
+ /** The declared shape of `hive()`'s output, given the member action's own `output`. */
48
+ export type HiveOutput<MOut extends AnySchema> = StandardSchemaV1<
49
+ unknown,
50
+ HiveResult<InferOutput<MOut>>
51
+ >;
52
+
53
+ /** What a member failure means for its siblings. No default: both answers are somebody's bug. */
54
+ export type HiveMemberError =
55
+ /** Stop. Siblings that have not started are `skipped`; ones in flight see the aborted signal. */
56
+ | 'abort'
57
+ /** Record it as `failed` and keep going — a partial harvest is the point of a hive. */
58
+ | 'collect';
59
+
60
+ export const SKIPPED_ABORTED = 'a sibling failed and onMemberError is abort';
61
+
62
+ /**
63
+ * The OTHER way a member never runs, and a different fact: `split` produced nothing at this
64
+ * index. Its own string because `SKIPPED_ABORTED` named a cause that did not happen — a caller
65
+ * reading it retries the tail against a hive that stopped early, when the split is what to fix.
66
+ */
67
+ export const SKIPPED_NO_INPUT = 'split produced no input at this index';
68
+
69
+ /**
70
+ * The member's `output` embedded verbatim in the `ok` arm, so a hive over `summarisePost` publishes
71
+ * a summary in its OpenAPI response and its MCP `outputSchema` — not an opaque object.
72
+ *
73
+ * A DISCRIMINATED union, not a plain one: `status` routes the parse, so a malformed `ok` reports
74
+ * that arm's issues instead of all three arms' at once.
75
+ */
76
+ export function hiveResultSchema(output: AnySchema): AnySchema {
77
+ const member = t.discriminatedUnion(
78
+ 'status',
79
+ t.object({ status: t.literal('ok'), index: t.number.int(), value: output }),
80
+ t.object({
81
+ status: t.literal('failed'),
82
+ index: t.number.int(),
83
+ code: t.string,
84
+ reason: t.string,
85
+ }),
86
+ t.object({ status: t.literal('skipped'), index: t.number.int(), reason: t.string }),
87
+ );
88
+ return t.object({
89
+ members: t.array(member),
90
+ ok: t.number.int(),
91
+ failed: t.number.int(),
92
+ skipped: t.number.int(),
93
+ tokens: t.number.int(),
94
+ cost: t.money,
95
+ });
96
+ }
package/src/hive.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `hive()` — one action fanned out over many inputs, declared as an `action`.
3
+ *
4
+ * The fourth instance of the framework's factory rule, after `llm()`, `backfill()` and `agent()`:
5
+ * a fan-out is still one server-authoritative operation with an input schema, an output schema and
6
+ * a policy, so this returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`,
7
+ * `.job()`, `.contract()` and its manifest row without a line here.
8
+ *
9
+ * It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
10
+ * loop gets four things wrong every time: it takes the actor from somewhere other than the request,
11
+ * it reports results in completion order, it cannot tell "ran and failed" from "never ran", and it
12
+ * has no ceiling — so the first bad split spends the whole budget in parallel.
13
+ */
14
+
15
+ import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
16
+ import { action, actionName } from '@ultimat3/action';
17
+ import type { Ctx } from '@ultimat3/core';
18
+ import { throwIfAborted, withSpan } from '@ultimat3/core';
19
+ import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
20
+ import type { BudgetLimits } from './budget';
21
+ import { BudgetLedger, currentBudget, withBudget } from './budget';
22
+ import { HiveEmptyError } from './hive-errors';
23
+ import { runPool } from './hive-pool';
24
+ import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
25
+ import { hiveResultSchema } from './hive-result';
26
+ import type { LlmBudget } from './llm';
27
+
28
+ /**
29
+ * Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
30
+ * is a floor to start from, not a number measured off any run: the framework cannot know a
31
+ * provider's concurrency allowance, and a default read off one benchmark would be wrong for
32
+ * everybody else's account. Raise it in the declaration once the run demonstrably fits.
33
+ */
34
+ const DEFAULT_CONCURRENCY = 4;
35
+
36
+ /**
37
+ * Below this many members the split is not fanned out at all. A member carries a fixed cost — a
38
+ * child context, a derived ledger, a whole model call's handshake — and paying it in parallel for
39
+ * one or two items buys nothing but a second way for the run to fail.
40
+ */
41
+ const DEFAULT_MIN_MEMBERS = 2;
42
+
43
+ export interface HiveSplitArgs<TInput extends StandardSchemaV1> {
44
+ readonly input: InferOutput<TInput>;
45
+ readonly ctx: Ctx;
46
+ }
47
+
48
+ export interface HiveDef<
49
+ TInput extends StandardSchemaV1,
50
+ MIn extends StandardSchemaV1,
51
+ MOut extends AnySchema,
52
+ > {
53
+ readonly input: TInput;
54
+ /**
55
+ * The action every member runs — an `agent()`, an `llm()`, or any action at all. Its own
56
+ * `policy` decides every member call and its own `input:` parses every member payload, which is
57
+ * what keeps a hive from being a second authz system.
58
+ */
59
+ readonly member: Action<MIn, MOut>;
60
+ /**
61
+ * The one declared place a run decides what the members are. Derived from `input` and `ctx` and
62
+ * from NOTHING a model emitted — that boundary is the same one `agent()` holds, and the reason
63
+ * both belong in the framework rather than in a loop somebody writes per feature.
64
+ */
65
+ split(
66
+ args: HiveSplitArgs<TInput>,
67
+ ): readonly InferInput<MIn>[] | Promise<readonly InferInput<MIn>[]>;
68
+ /** Members in flight at once. */
69
+ readonly concurrency?: number;
70
+ /** Below this many members the split runs serially instead of fanning out. */
71
+ readonly minMembers?: number;
72
+ readonly onMemberError: HiveMemberError;
73
+ /** Ceilings for the WHOLE fan-out. `tokensPerRun` is the run's, every member counted. */
74
+ readonly budget?: LlmBudget & { readonly tokensPerRun?: number };
75
+ readonly policy: ActionPolicy;
76
+ readonly mcp?: ActionMcp;
77
+ }
78
+
79
+ export function hive<
80
+ TInput extends StandardSchemaV1,
81
+ MIn extends StandardSchemaV1,
82
+ MOut extends AnySchema,
83
+ >(def: HiveDef<TInput, MIn, MOut>): Action<TInput, HiveOutput<MOut>> {
84
+ // The one cast in this file, and it narrows nothing at runtime: `hiveResultSchema` builds the
85
+ // shape `HiveResult<InferOutput<MOut>>` describes, and this restates that in the type system
86
+ // rather than making the caller infer it back out of a nested `t.discriminatedUnion`.
87
+ const output = hiveResultSchema(def.member.output) as HiveOutput<MOut>;
88
+ return action<TInput, HiveOutput<MOut>>({
89
+ input: def.input,
90
+ output,
91
+ policy: def.policy,
92
+ ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
93
+ handle: (args) => run(def, args),
94
+ });
95
+ }
96
+
97
+ async function run<
98
+ TInput extends StandardSchemaV1,
99
+ MIn extends StandardSchemaV1,
100
+ MOut extends AnySchema,
101
+ >(
102
+ def: HiveDef<TInput, MIn, MOut>,
103
+ args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
104
+ ): Promise<HiveResult<InferOutput<MOut>>> {
105
+ const name = actionName(def.member);
106
+ const inputs = await def.split({ input: args.input, ctx: args.ctx });
107
+ if (inputs.length === 0) throw new HiveEmptyError({ member: name });
108
+
109
+ const floor = def.minMembers ?? DEFAULT_MIN_MEMBERS;
110
+ // A split below the floor still runs every input it produced — dropping one would be silent
111
+ // data loss — it just stops paying for a pool to do it.
112
+ const width =
113
+ inputs.length < floor
114
+ ? 1
115
+ : Math.max(1, Math.min(def.concurrency ?? DEFAULT_CONCURRENCY, inputs.length));
116
+
117
+ return withSpan('ai.hive', async (span) => {
118
+ span.setAttributes({
119
+ 'hive.member': name,
120
+ 'hive.members': inputs.length,
121
+ 'hive.concurrency': width,
122
+ 'hive.on_member_error': def.onMemberError,
123
+ });
124
+ const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
125
+ const result = await withBudget(ledger, () =>
126
+ runPool<InferInput<MIn>, InferOutput<MOut>>({
127
+ inputs,
128
+ width,
129
+ ctx: args.ctx,
130
+ onMemberError: def.onMemberError,
131
+ member: (payload) => def.member(payload),
132
+ }),
133
+ );
134
+ // The caller went away DURING the fan-out. Distinct from `onMemberError: 'abort'`, which is a
135
+ // completed run that stopped early and has a partial harvest worth returning: here there is
136
+ // nobody left to hand it to, so unwind the way every other abort in this package does.
137
+ throwIfAborted(args.ctx);
138
+ const report = await ledger.report();
139
+ const counts = tally(result);
140
+ span.setAttributes({ ...counts, 'hive.tokens': report.requestTokens });
141
+ return {
142
+ members: result,
143
+ ok: counts['hive.ok'],
144
+ failed: counts['hive.failed'],
145
+ skipped: counts['hive.skipped'],
146
+ tokens: report.requestTokens,
147
+ cost: report.cost,
148
+ };
149
+ });
150
+ }
151
+
152
+ function tally<O>(members: readonly HiveMember<O>[]): {
153
+ 'hive.ok': number;
154
+ 'hive.failed': number;
155
+ 'hive.skipped': number;
156
+ } {
157
+ return {
158
+ 'hive.ok': members.filter((one) => one.status === 'ok').length,
159
+ 'hive.failed': members.filter((one) => one.status === 'failed').length,
160
+ 'hive.skipped': members.filter((one) => one.status === 'skipped').length,
161
+ };
162
+ }
163
+
164
+ function limitsOf<
165
+ TInput extends StandardSchemaV1,
166
+ MIn extends StandardSchemaV1,
167
+ MOut extends AnySchema,
168
+ >(def: HiveDef<TInput, MIn, MOut>): BudgetLimits {
169
+ const budget = def.budget;
170
+ return {
171
+ ...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
172
+ ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
173
+ // The ledger's `request` scope accumulates across every call made under it, which for a hive
174
+ // under `withBudget` is every member's every turn.
175
+ ...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
176
+ };
177
+ }
package/src/index.ts CHANGED
@@ -4,8 +4,12 @@
4
4
  /** Re-exported so an `llm` file needs one import, not two. Same object as schema's. */
5
5
  export type { Infer } from '@ultimat3/schema';
6
6
  export { t } from '@ultimat3/schema';
7
- export type { AgentBudget, AgentDef, AgentVarsArgs } from './agent';
7
+ export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent';
8
8
  export { agent } from './agent';
9
+ export type { AgentBudgetFact, AgentFact } from './agent-facts';
10
+ export { describeAgents, resetAgents } from './agent-facts';
11
+ export type { AgentJobOptions } from './agent-job';
12
+ export { agentJob } from './agent-job';
9
13
  export type {
10
14
  BudgetLedgerInput,
11
15
  BudgetLimits,
@@ -87,17 +91,16 @@ export {
87
91
  promptsWithoutEvals,
88
92
  resetEvals,
89
93
  } from './evals';
94
+ export type { AiFetch } from './fetch-seam';
90
95
  export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
91
96
  export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
92
- export type {
93
- LlmAction,
94
- LlmBudget,
95
- LlmCache,
96
- LlmDef,
97
- LlmSemanticCache,
98
- LlmVarsArgs,
99
- } from './llm';
97
+ export type { HiveDef, HiveSplitArgs } from './hive';
98
+ export { hive } from './hive';
99
+ export { HiveEmptyError } from './hive-errors';
100
+ export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
101
+ export type { LlmAction, LlmBudget, LlmDef, LlmVarsArgs } from './llm';
100
102
  export { llm } from './llm';
103
+ export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
101
104
  export type { LlmStreamChunk } from './llm-stream';
102
105
  export type { Effort, ModelId, ModelReasoning, ModelSpec, ThinkingMode } from './models';
103
106
  export {
@@ -188,6 +191,7 @@ export {
188
191
  aiGateway,
189
192
  aiRedactor,
190
193
  configureAi,
194
+ MAX_SEMANTIC_CACHE_SCOPES,
191
195
  resetAiRuntime,
192
196
  semanticCacheFor,
193
197
  } from './runtime';
@@ -201,13 +205,14 @@ export {
201
205
  numericTolerance,
202
206
  } from './scorers';
203
207
  export type {
208
+ AgentTool,
204
209
  JsonSchema,
205
210
  LlmTool,
206
211
  LlmToolCall,
207
212
  LlmToolResult,
208
213
  ProjectableAction,
209
214
  } from './tools';
210
- export { runLlmToolCall, toLlmTool, toLlmTools } from './tools';
215
+ export { asProjectableAction, runLlmToolCall, toLlmTool, toLlmTools } from './tools';
211
216
  export type {
212
217
  HybridSearchInput,
213
218
  MemoryVectorStoreInput,
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The semantic cache half of `llm()`: what a declaration may partition on, and the store one
3
+ * declaration reaches. Split from `llm.ts` so that file stays the model call itself.
4
+ *
5
+ * A scope is a separate cache INSTANCE, never a filter over a shared one, and the instance key
6
+ * carries the prompt VERSION as well — which is what makes "editing a prompt requires a version
7
+ * bump" invalidate the cache: a bumped version reaches a different store, so an old answer cannot
8
+ * survive a prompt edit no matter how similar the text.
9
+ */
10
+
11
+ import type { Ctx } from '@ultimat3/core';
12
+ import { parseDuration } from '@ultimat3/time';
13
+ import { embedOne, fnv1a } from './embeddings';
14
+ import { aiEmbedder, semanticCacheFor } from './runtime';
15
+
16
+ /** What a `scope` may decide from. The same pair `vars()` receives, and for the same reason. */
17
+ export interface LlmScopeArgs<TParsed> {
18
+ readonly input: TParsed;
19
+ readonly ctx: Ctx;
20
+ }
21
+
22
+ export interface LlmSemanticCache<TParsed> {
23
+ /** Cosine floor. Below ~0.9 unrelated prompts collide and the cache answers the wrong one. */
24
+ readonly threshold?: number;
25
+ /** Entry lifetime as a duration string — `'7d'`, `'12h'`. `@ultimat3/time` owns the grammar. */
26
+ readonly ttl?: string;
27
+ /**
28
+ * Partition key. Each scope is a separate cache instance, never a filter over a shared one:
29
+ * cosine similarity has no notion of a tenant, so a shared cache answers one tenant with
30
+ * another's data — by construction, since `lookup` is a nearest neighbour with no predicate.
31
+ *
32
+ * **Omitting it is safe.** The default is the narrowest key the ctx can supply — the actor,
33
+ * its kind and its org — which is `@ultimat3/query`'s `readAuthority` rule applied here: a
34
+ * declaration that says nothing gets the narrowest partition, which is always correct, and
35
+ * WIDENING is a written statement about what the answers are. It defaulted to `'global'`, so a
36
+ * `cache: { semantic: { ttl: '1h' } }` put every tenant in one store.
37
+ *
38
+ * **Breaking: it receives `{ input, ctx }`, not the bare `input`.** Taking `input` alone meant
39
+ * the partition could only be chosen from a value the caller sends, which is the one thing a
40
+ * partition may never be chosen by. `vars()` on the same declaration already takes the pair.
41
+ */
42
+ readonly scope?: (args: LlmScopeArgs<TParsed>) => string;
43
+ }
44
+
45
+ export interface LlmCache<TParsed> {
46
+ readonly semantic: LlmSemanticCache<TParsed>;
47
+ // `05-caching.md` also declares `invalidates: [tag.post]` here. It is deliberately absent
48
+ // until `@ultimat3/cache`'s fan-out can reach something that is not a `CacheTier`: storing
49
+ // tags that the ONE invalidation path never visits would read as wired and silently not be.
50
+ // Today the invalidation story is the prompt version (a bump reaches a different store) and
51
+ // `ttl`.
52
+ }
53
+
54
+ export interface PromptCache {
55
+ lookup(): Promise<unknown>;
56
+ remember(value: unknown): Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * The partition a declaration that named none gets: the narrowest key this call can prove, which
61
+ * is the actor itself. Verbatim the shape `@ultimat3/query`'s `actorAuthority` builds, for the
62
+ * same reason it is JSON rather than a joined string — an actor id is app data and may carry any
63
+ * separator, and a value that can spell a boundary can spell somebody else's.
64
+ *
65
+ * `ctx.actor` is never absent (`createContext` defaults it to `anonymousActor()`), so every
66
+ * anonymous caller shares one partition — which is what the anonymous actor already means
67
+ * everywhere else in the framework.
68
+ */
69
+ function actorScope(ctx: Ctx): string {
70
+ return JSON.stringify([ctx.actor.kind, ctx.actor.id, ctx.actor.orgId ?? null]);
71
+ }
72
+
73
+ /**
74
+ * The semantic cache for one declaration, or `undefined` when none was declared. The instance
75
+ * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
76
+ * requires a version bump" invalidate the cache: a bumped version reaches a different store,
77
+ * so an old answer cannot survive a prompt edit no matter how similar the text.
78
+ */
79
+ export interface OpenCacheArgs<TParsed> {
80
+ readonly cache: LlmCache<TParsed> | undefined;
81
+ /** Identity and content hash of the prompt artifact — the version half of the partition. */
82
+ readonly prompt: { readonly ref: string; readonly hash: string };
83
+ readonly input: TParsed;
84
+ readonly ctx: Ctx;
85
+ /** The rendered, redacted prompt text: what is embedded and what the entry is keyed on. */
86
+ readonly rendered: string;
87
+ }
88
+
89
+ export async function openCache<TParsed>(
90
+ args: OpenCacheArgs<TParsed>,
91
+ ): Promise<PromptCache | undefined> {
92
+ const semantic = args.cache?.semantic;
93
+ if (semantic === undefined) return undefined;
94
+ const scope = semantic.scope?.({ input: args.input, ctx: args.ctx }) ?? actorScope(args.ctx);
95
+ const store = semanticCacheFor(`${args.prompt.ref}#${args.prompt.hash}::${scope}`);
96
+ const embedding = Array.from(await embedOne(aiEmbedder(), args.rendered));
97
+ const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
98
+ return {
99
+ async lookup(): Promise<unknown> {
100
+ return (await store.lookup(embedding, semantic.threshold))?.value;
101
+ },
102
+ remember(value: unknown): Promise<void> {
103
+ return store.remember(
104
+ `${args.prompt.hash}:${fnv1a(args.rendered).toString(16)}`,
105
+ embedding,
106
+ value,
107
+ ttlMs === undefined ? {} : { ttlMs },
108
+ );
109
+ },
110
+ };
111
+ }
package/src/llm.ts CHANGED
@@ -28,10 +28,8 @@ import { withSpan } from '@ultimat3/core';
28
28
  import type { Money } from '@ultimat3/money';
29
29
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
30
30
  import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
31
- import { parseDuration } from '@ultimat3/time';
32
31
  import type { BudgetLimits } from './budget';
33
32
  import { BudgetLedger, currentBudget, withBudget } from './budget';
34
- import { embedOne, fnv1a } from './embeddings';
35
33
  import {
36
34
  LlmOutputInvalidError,
37
35
  LlmRefusedError,
@@ -39,6 +37,8 @@ import {
39
37
  LlmTruncatedError,
40
38
  } from './errors';
41
39
  import type { Gateway } from './gateway';
40
+ import type { LlmCache } from './llm-cache';
41
+ import { openCache } from './llm-cache';
42
42
  import type { LlmSink, LlmStreamChunk } from './llm-stream';
43
43
  import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
44
44
  import type { ModelId } from './models';
@@ -46,7 +46,7 @@ import { DEFAULT_MODEL, moreCapableThan } from './models';
46
46
  import type { Prompt, PromptVars } from './prompt';
47
47
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
48
48
  import { assertNoSecrets } from './redaction';
49
- import { aiEmbedder, aiGateway, aiRedactor, semanticCacheFor } from './runtime';
49
+ import { aiGateway, aiRedactor } from './runtime';
50
50
  import type { LlmTool } from './tools';
51
51
 
52
52
  /**
@@ -65,27 +65,6 @@ const ATTEMPTS = 2;
65
65
  */
66
66
  const DEFAULT_MAX_TOKENS = 4_096;
67
67
 
68
- export interface LlmSemanticCache<TParsed> {
69
- /** Cosine floor. Below ~0.9 unrelated prompts collide and the cache answers the wrong one. */
70
- readonly threshold?: number;
71
- /** Entry lifetime as a duration string — `'7d'`, `'12h'`. `@ultimat3/time` owns the grammar. */
72
- readonly ttl?: string;
73
- /**
74
- * Partition key, from the parsed input. Each scope is a separate cache: cosine similarity
75
- * has no notion of a tenant, so a shared cache answers one tenant with another's data.
76
- */
77
- readonly scope?: (input: TParsed) => string;
78
- }
79
-
80
- export interface LlmCache<TParsed> {
81
- readonly semantic: LlmSemanticCache<TParsed>;
82
- // `05-caching.md` also declares `invalidates: [tag.post]` here. It is deliberately absent
83
- // until `@ultimat3/cache`'s fan-out can reach something that is not a `CacheTier`: storing
84
- // tags that the ONE invalidation path never visits would read as wired and silently not be.
85
- // Today the invalidation story is the prompt version (a bump reaches a different store) and
86
- // `ttl`.
87
- }
88
-
89
68
  /** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
90
69
  export interface LlmBudget {
91
70
  /** Prompt tokens. */
@@ -235,7 +214,13 @@ async function generate<
235
214
  // A cached answer is still data of unknown provenance, so it goes through the schema like
236
215
  // any other. One that no longer fits — the schema moved under it — is a miss, not a
237
216
  // failure: the model can produce a fresh answer, and refusing would be worse than paying.
238
- const cache = await openCache(def, args.input, rendered);
217
+ const cache = await openCache({
218
+ cache: def.cache,
219
+ prompt: { ref: prompt.ref, hash: prompt.hash },
220
+ input: args.input,
221
+ ctx: args.ctx,
222
+ rendered,
223
+ });
239
224
  const hit = await accept(def.output, await cache?.lookup());
240
225
  span.setAttribute('llm.cache.hit', hit !== undefined);
241
226
  if (hit !== undefined) return hit.value;
@@ -250,6 +235,10 @@ async function generate<
250
235
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
251
236
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
252
237
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
238
+ // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
239
+ // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
240
+ // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
241
+ signal: args.ctx.signal,
253
242
  };
254
243
  const request: GenerateRequest = { ...base, tools: [respond] };
255
244
 
@@ -447,44 +436,3 @@ function parseJsonish(text: string): unknown {
447
436
  return undefined;
448
437
  }
449
438
  }
450
-
451
- interface PromptCache {
452
- lookup(): Promise<unknown>;
453
- remember(value: unknown): Promise<void>;
454
- }
455
-
456
- /**
457
- * The semantic cache for one declaration, or `undefined` when none was declared. The instance
458
- * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
459
- * requires a version bump" invalidate the cache: a bumped version reaches a different store,
460
- * so an old answer cannot survive a prompt edit no matter how similar the text.
461
- */
462
- async function openCache<
463
- TInput extends StandardSchemaV1,
464
- TOutput extends StandardSchemaV1,
465
- V extends PromptVars,
466
- >(
467
- def: LlmDef<TInput, TOutput, V>,
468
- input: InferOutput<TInput>,
469
- rendered: string,
470
- ): Promise<PromptCache | undefined> {
471
- const semantic = def.cache?.semantic;
472
- if (semantic === undefined) return undefined;
473
- const scope = semantic.scope?.(input) ?? 'global';
474
- const store = semanticCacheFor(`${def.prompt.ref}#${def.prompt.hash}::${scope}`);
475
- const embedding = Array.from(await embedOne(aiEmbedder(), rendered));
476
- const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
477
- return {
478
- async lookup(): Promise<unknown> {
479
- return (await store.lookup(embedding, semantic.threshold))?.value;
480
- },
481
- remember(value: unknown): Promise<void> {
482
- return store.remember(
483
- `${def.prompt.hash}:${fnv1a(rendered).toString(16)}`,
484
- embedding,
485
- value,
486
- ttlMs === undefined ? {} : { ttlMs },
487
- );
488
- },
489
- };
490
- }