1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import { listLocations } from "./names.ts";
13import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
14import { listAlchemyReasoningEngines } from "./ReasoningEngine.ts";
15import {
16 AiPlatformNotResolved,
17 AiPlatformStillExists,
18 collectPages,
19 encodeDescription,
20 hasDescriptionOwnership,
21 jsonEqual,
22 lastSegment,
23 locationParent,
24 normalizeLocation,
25 parentBefore,
26 parseDescription,
27 parseResourceName,
28 toPhysicalId,
29} from "./shared.ts";
30
31const COLLECTION = "memories";
32
33export type MemoryMetadataValue = {
34 boolValue?: boolean;
35 doubleValue?: number;
36 stringValue?: string;
37 timestampValue?: string;
38};
39
40export type ReasoningEnginesMemoryProps = {
41 /**
42 * Parent Reasoning Engine. Full name
43 * `projects/{project}/locations/{location}/reasoningEngines/{engine}`
44 * or the engine id (combined with `location`). Immutable — changing it
45 * replaces the memory.
46 */
47 reasoningEngine: string;
48 /**
49 * Vertex AI location. Used when `reasoningEngine` is a bare id.
50 * Immutable. @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Memory id. If omitted, a unique RFC1035 name is generated. Immutable
55 * — changing it replaces the memory.
56 */
57 memoryId?: string;
58 /**
59 * Isolation scope. Required and immutable. Values cannot contain `*`.
60 */
61 scope: Record<string, string>;
62 /**
63 * Semantic fact extracted from source content.
64 */
65 fact?: string;
66 /**
67 * Display name.
68 */
69 displayName?: string;
70 /**
71 * Human-readable description. Memory has no labels field, so Alchemy
72 * ownership is stored in a `[alchemy …]` prefix for `list` / nuke.
73 */
74 description?: string;
75 /**
76 * User-provided metadata (not generated by Memory Bank).
77 */
78 metadata?: Record<string, MemoryMetadataValue>;
79 /**
80 * TTL for this memory (e.g. `"86400s"`). Input only.
81 */
82 ttl?: string;
83 /**
84 * Absolute expire time (RFC3339).
85 */
86 expireTime?: string;
87};
88
89export type ReasoningEnginesMemory = Resource<
90 "GCP.AIPlatform.ReasoningEnginesMemory",
91 ReasoningEnginesMemoryProps,
92 {
93 /** Full resource name. */
94 name: string;
95 /** Memory id (last path segment). */
96 memoryId: string;
97 /** Parent Reasoning Engine resource name. */
98 reasoningEngine: string;
99 /** Project id. */
100 project: string;
101 /** Location id. */
102 location: string;
103 /** Isolation scope. */
104 scope: aiplatform.StringMap;
105 /** Semantic fact. */
106 fact: string | undefined;
107 /** Display name. */
108 displayName: string | undefined;
109 /** User description with the Alchemy ownership prefix stripped. */
110 description: string | undefined;
111 /** RFC3339 expire time. */
112 expireTime: string | undefined;
113 /** RFC3339 creation timestamp. */
114 createTime: string | undefined;
115 /** RFC3339 last-update timestamp. */
116 updateTime: string | undefined;
117 },
118 never,
119 Providers
120>;
121
122/**
123 * A Vertex AI Reasoning Engine memory — scoped semantic knowledge used
124 * by an Agent Engine Memory Bank.
125 *
126 * Memory has no labels, so Alchemy stamps ownership into the
127 * description. Changing `memoryId`, `reasoningEngine`, `location`, or
128 * `scope` replaces the memory. Fact, description, and metadata update in
129 * place.
130 *
131 * ### Creating a Memory
132 * **Example:** User-scoped fact
133 * ```typescript
134 * const memory = yield* GCP.AIPlatform.ReasoningEnginesMemory("Pref", {
135 * reasoningEngine: engine.name,
136 * scope: { user_id: "user-123" },
137 * fact: "the user prefers concise answers",
138 * });
139 * ```
140 *
141 * @resource
142 * @category AIPlatform
143 */
144export const ReasoningEnginesMemory = Resource<ReasoningEnginesMemory>(
145 "GCP.AIPlatform.ReasoningEnginesMemory",
146);
147
148export class ReasoningEnginesMemoryNotResolved extends Data.TaggedError(
149 "GCP.AIPlatform.ReasoningEnginesMemoryNotResolved",
150)<{
151 name: string;
152}> {}
153
154const engineNameOf = (
155 project: string,
156 location: string,
157 reasoningEngine: string,
158) =>
159 reasoningEngine.includes("/")
160 ? reasoningEngine
161 : `${locationParent(project, location)}/reasoningEngines/${reasoningEngine}`;
162
163const resourceName = (parent: string, memoryId: string) =>
164 `${parent}/${COLLECTION}/${memoryId}`;
165
166const toAttrs = (
167 memory: aiplatform.GoogleCloudAiplatformV1Memory,
168 project: string,
169) => {
170 const name = memory.name ?? "";
171 const parsed = parseResourceName(name, COLLECTION);
172 const description = parseDescription(memory.description);
173 return {
174 name,
175 memoryId: parsed.id,
176 reasoningEngine: parentBefore(name, COLLECTION),
177 project: parsed.project || project,
178 location: parsed.location,
179 scope: memory.scope ?? {},
180 fact: memory.fact,
181 displayName: memory.displayName,
182 description: description.description,
183 expireTime: memory.expireTime,
184 createTime: memory.createTime,
185 updateTime: memory.updateTime,
186 };
187};
188
189const getByName = (name: string) =>
190 aiplatform
191 .getProjectsLocationsReasoningEnginesMemories({ name })
192 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
193
194const waitUntilExists = (name: string) =>
195 getByName(name).pipe(
196 Effect.filterOrFail(
197 (memory): memory is aiplatform.GoogleCloudAiplatformV1Memory =>
198 memory !== undefined,
199 () => new AiPlatformNotResolved({ name }),
200 ),
201 Effect.retry({
202 while: (error) => error._tag === "GCP.AIPlatform.NotResolved",
203 times: 8,
204 schedule: Schedule.spaced("2 seconds"),
205 }),
206 );
207
208const waitUntilGone = (name: string) =>
209 getByName(name).pipe(
210 Effect.filterOrFail(
211 (memory) => memory === undefined,
212 () => new AiPlatformStillExists({ name }),
213 ),
214 Effect.asVoid,
215 Effect.retry({
216 while: (error) => error._tag === "GCP.AIPlatform.StillExists",
217 times: 10,
218 schedule: Schedule.spaced("2 seconds"),
219 }),
220 );
221
222const listMemories = (parent: string) =>
223 collectPages(
224 aiplatform.listProjectsLocationsReasoningEnginesMemories.pages({
225 parent,
226 pageSize: 100,
227 }),
228 ).pipe(
229 Effect.map((pages) => pages.flatMap((page) => page.memories ?? [])),
230 Effect.catchTag("NotFound", () =>
231 Effect.succeed<aiplatform.GoogleCloudAiplatformV1Memory[]>([]),
232 ),
233 );
234
235export const ReasoningEnginesMemoryProvider = () =>
236 Provider.succeed(ReasoningEnginesMemory, {
237 stables: [
238 "name",
239 "memoryId",
240 "reasoningEngine",
241 "project",
242 "location",
243 "createTime",
244 ],
245
246 diff: Effect.fn(function* ({ news, olds, output }) {
247 const env = yield* GcpEnvironment.current;
248 if (!isResolved(news)) return undefined;
249 const previousId = olds?.memoryId ?? output?.memoryId;
250 const nextId = news.memoryId ?? previousId;
251 const previousParent = lastSegment(
252 olds?.reasoningEngine ?? output?.reasoningEngine ?? "",
253 );
254 const nextParent = lastSegment(news.reasoningEngine);
255 const previousLocation = normalizeLocation(
256 olds?.location ?? output?.location,
257 env.region,
258 );
259 const nextLocation = normalizeLocation(
260 news.location ?? output?.location,
261 env.region,
262 );
263 const scopeChanged =
264 olds !== undefined && !jsonEqual(news.scope, olds.scope);
265 const replace =
266 (previousId !== undefined &&
267 nextId !== undefined &&
268 nextId !== previousId) ||
269 (previousParent.length > 0 && previousParent !== nextParent) ||
270 previousLocation !== nextLocation ||
271 scopeChanged;
272 if (!replace) return undefined;
273 return {
274 action: "replace" as const,
275 deleteFirst:
276 previousLocation === nextLocation &&
277 previousParent === nextParent &&
278 previousId !== undefined &&
279 nextId === previousId,
280 };
281 }),
282
283 read: Effect.fn(function* ({ id, olds, output }) {
284 const env = yield* GcpEnvironment.current;
285 const location = normalizeLocation(
286 olds?.location ?? output?.location,
287 env.region,
288 );
289 const engine = olds?.reasoningEngine ?? output?.reasoningEngine;
290 // A create interrupted before its engine resolved has nothing to find.
291 if (output?.name === undefined && typeof engine !== "string") {
292 return undefined;
293 }
294 const parent = engineNameOf(env.project, location, engine ?? "");
295 const memoryId = yield* toPhysicalId(
296 id,
297 olds?.memoryId,
298 output?.memoryId,
299 );
300 const name = output?.name ?? resourceName(parent, memoryId);
301 const existing = yield* getByName(name);
302 if (existing === undefined) return undefined;
303 const attrs = toAttrs(existing, env.project);
304 const parsed = parseDescription(existing.description);
305 return (yield* hasAlchemyLabels(id, parsed.labels))
306 ? attrs
307 : Unowned(attrs);
308 }),
309
310 list: () =>
311 Effect.gen(function* () {
312 const env = yield* GcpEnvironment.current;
313 const engines = (yield* Effect.forEach(
314 listLocations(env.region),
315 (location) => listAlchemyReasoningEngines(env.project, location),
316 )).flat();
317 const memories = yield* Effect.forEach(
318 engines,
319 (engine) =>
320 engine.name
321 ? listMemories(engine.name)
322 : Effect.succeed<aiplatform.GoogleCloudAiplatformV1Memory[]>([]),
323 { concurrency: 4 },
324 );
325 return memories
326 .flat()
327 .filter((memory) => hasDescriptionOwnership(memory.description))
328 .map((memory) => toAttrs(memory, env.project));
329 }),
330
331 reconcile: Effect.fn(function* ({ id, news, output }) {
332 const env = yield* GcpEnvironment.current;
333 const location = normalizeLocation(
334 news.location ?? output?.location,
335 env.region,
336 );
337 const parent = engineNameOf(env.project, location, news.reasoningEngine);
338 const memoryId = yield* toPhysicalId(id, news.memoryId, output?.memoryId);
339 const name = resourceName(parent, memoryId);
340 const internal = yield* createInternalLabels(id);
341 const stampedDescription = encodeDescription(internal, news.description);
342
343 let current = yield* getByName(output?.name ?? name);
344
345 if (current === undefined) {
346 const created = yield* aiplatform
347 .createProjectsLocationsReasoningEnginesMemories({
348 parent,
349 memoryId,
350 body: {
351 scope: news.scope,
352 fact: news.fact,
353 displayName: news.displayName,
354 description: stampedDescription,
355 metadata: news.metadata,
356 ttl: news.ttl,
357 expireTime: news.expireTime,
358 },
359 })
360 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
361 if (created !== undefined) {
362 yield* waitForOperation(created, { alreadyExistsOk: true });
363 }
364 const createdName =
365 resourceNameFromOperation(created ?? {}) ?? output?.name ?? name;
366 current = yield* waitUntilExists(createdName);
367 }
368
369 if (current === undefined || current.name === undefined) {
370 return yield* new ReasoningEnginesMemoryNotResolved({ name });
371 }
372
373 const observedName = current.name;
374 const observed = parseDescription(current.description);
375 const factChanged = (current.fact ?? "") !== (news.fact ?? "");
376 const displayChanged =
377 (current.displayName ?? "") !== (news.displayName ?? "");
378 const descriptionChanged =
379 (observed.description ?? "") !== (news.description ?? "");
380 const metadataChanged = !jsonEqual(current.metadata, news.metadata);
381
382 if (
383 factChanged ||
384 displayChanged ||
385 descriptionChanged ||
386 metadataChanged
387 ) {
388 const updateMask = [
389 factChanged ? "fact" : undefined,
390 displayChanged ? "display_name" : undefined,
391 descriptionChanged ? "description" : undefined,
392 metadataChanged ? "metadata" : undefined,
393 ].filter((field): field is string => field !== undefined);
394 const patched =
395 yield* aiplatform.patchProjectsLocationsReasoningEnginesMemories({
396 name: observedName,
397 updateMask: updateMask.join(","),
398 body: {
399 name: observedName,
400 fact: news.fact,
401 displayName: news.displayName,
402 description: stampedDescription,
403 metadata: news.metadata,
404 },
405 });
406 yield* waitForOperation(patched);
407 current = yield* getByName(observedName);
408 }
409
410 if (current === undefined) {
411 return yield* new ReasoningEnginesMemoryNotResolved({
412 name: observedName,
413 });
414 }
415 return toAttrs(current, env.project);
416 }),
417
418 delete: Effect.fn(function* ({ output }) {
419 const operation = yield* aiplatform
420 .deleteProjectsLocationsReasoningEnginesMemories({
421 name: output.name,
422 })
423 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
424 if (operation !== undefined) {
425 yield* waitForOperation(operation, { notFoundOk: true });
426 }
427 yield* waitUntilGone(output.name);
428 }),
429 });