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 * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20import { listLocations } from "./names.ts";
21import {
22 encodeOwnership,
23 hasAlchemyLabelKeys,
24 hasOwnershipMarker,
25 normalizeLocation,
26 parentOf,
27 parseOwnership,
28 parseResourceName,
29 rfc1035,
30 resourceNameFromOperation,
31 stringMapOf,
32 userLabels,
33 waitForOperation,
34} from "./internal.ts";
35
36export type AgentTool = {
37 /**
38 * Tool type: `code_execution`, `filesystem`, `google_search`,
39 * `mcp_server`, or `url_context`.
40 */
41 type?: string;
42 /**
43 * MCP server name. Only when `type` is `mcp_server`.
44 */
45 name?: string;
46 /**
47 * MCP server endpoint URL. Only when `type` is `mcp_server`.
48 */
49 url?: string;
50 /**
51 * MCP server headers (for example authentication). Only when `type` is
52 * `mcp_server`.
53 */
54 headers?: Record<string, string>;
55};
56
57export type AgentProps = {
58 /**
59 * Agent id (the `{agent}` segment of
60 * `projects/{project}/locations/{location}/agents/{agent}`). If omitted,
61 * a unique name is generated. Must match
62 * `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable — changing it replaces
63 * the agent.
64 */
65 agentId?: string;
66 /**
67 * Region. Immutable — changing it replaces the agent.
68 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
69 */
70 location?: string;
71 /**
72 * Base agent. Required by the API.
73 * @default "antigravity-preview-05-2026"
74 */
75 baseAgent?: string;
76 /**
77 * Description. Alchemy also stamps ownership here when metadata is
78 * stripped by the API.
79 */
80 description?: string;
81 /**
82 * System instruction passed to the LLM.
83 */
84 systemInstruction?: string;
85 /**
86 * Tools available to the agent.
87 */
88 tools?: AgentTool[];
89 /**
90 * User metadata. Alchemy ownership labels are merged in automatically.
91 */
92 metadata?: Record<string, string>;
93 /**
94 * Base environment (`remote`, an environment id, or a config struct).
95 */
96 baseEnvironment?: unknown;
97};
98
99export type Agent = Resource<
100 "GCP.AIPlatform.Agent",
101 AgentProps,
102 {
103 /** Full resource name `projects/{project}/locations/{location}/agents/{agent}`. */
104 name: string;
105 /** Agent id (last path segment). */
106 agentId: string;
107 /** Project id. */
108 project: string;
109 /** Location id. */
110 location: string;
111 /** Base agent identifier. */
112 baseAgent: string | undefined;
113 /** User description with the Alchemy ownership prefix stripped. */
114 description: string | undefined;
115 /** System instruction. */
116 systemInstruction: string | undefined;
117 /** Configured tools. */
118 tools: AgentTool[];
119 /** User metadata (Alchemy ownership keys stripped). */
120 metadata: Record<string, string>;
121 /** Base environment. */
122 baseEnvironment: unknown;
123 /** Object type (`agent`). */
124 object: string | undefined;
125 /** RFC3339 creation timestamp. */
126 created: string | undefined;
127 /** RFC3339 last-update timestamp. */
128 updated: string | undefined;
129 },
130 never,
131 Providers
132>;
133
134/**
135 * A Vertex AI Agent — instructions and tool configuration for an LLM
136 * task.
137 *
138 * Agent id, location, and base agent are immutable. Description, system
139 * instruction, tools, and metadata update in place. Alchemy ownership is
140 * stored in `metadata` (and mirrored in `description`) so `list` / nuke
141 * can find the agent.
142 *
143 * ### Creating an Agent
144 * **Example:** Generated id
145 * ```typescript
146 * const agent = yield* GCP.AIPlatform.Agent("Support", {
147 * systemInstruction: "Answer product questions briefly.",
148 * metadata: { env: "test" },
149 * });
150 * ```
151 *
152 * **Example:** Explicit id and a search tool
153 * ```typescript
154 * const agent = yield* GCP.AIPlatform.Agent("Research", {
155 * agentId: "research-bot",
156 * tools: [{ type: "google_search" }],
157 * });
158 * ```
159 *
160 * @resource
161 * @category AIPlatform
162 */
163export const Agent = Resource<Agent>("GCP.AIPlatform.Agent");
164
165export class AgentNotResolved extends Data.TaggedError(
166 "GCP.AIPlatform.AgentNotResolved",
167)<{
168 name: string;
169}> {}
170
171export class AgentStillExists extends Data.TaggedError(
172 "GCP.AIPlatform.AgentStillExists",
173)<{
174 name: string;
175}> {}
176
177const DEFAULT_BASE_AGENT = "antigravity-preview-05-2026";
178
179const resourceName = (project: string, location: string, agentId: string) =>
180 `projects/${project}/locations/${location}/agents/${agentId}`;
181
182const toId = (id: string, agentId: string | undefined, existing?: string) =>
183 Effect.gen(function* () {
184 if (agentId !== undefined) return rfc1035(agentId);
185 if (existing !== undefined) return existing;
186 return rfc1035(
187 yield* createPhysicalName({
188 id,
189 maxLength: 63,
190 lowercase: true,
191 }),
192 );
193 });
194
195const toolsOf = (
196 tools:
197 | readonly AgentTool[]
198 | readonly aiplatform.GoogleCloudAiplatformV1AgentTool[]
199 | undefined,
200): AgentTool[] =>
201 (tools ?? []).map((tool) => ({
202 type: tool.type,
203 name: tool.name,
204 url: tool.url,
205 headers: stringMapOf(tool.headers),
206 }));
207
208const toAttrs = (
209 agent: aiplatform.GoogleCloudAiplatformV1Agent,
210 project: string,
211) => {
212 const name = agent.name ?? "";
213 const parsed = parseResourceName(name, "agents");
214 const ownership = parseOwnership(agent.description);
215 return {
216 name,
217 agentId: agent.id ?? parsed.id,
218 project: parsed.project || project,
219 location: parsed.location,
220 baseAgent: agent.base_agent,
221 description: ownership.text,
222 systemInstruction: agent.system_instruction,
223 tools: toolsOf(agent.tools),
224 metadata: userLabels(agent.metadata),
225 baseEnvironment: agent.base_environment,
226 object: agent.object,
227 created: agent.created,
228 updated: agent.updated,
229 };
230};
231
232const getByName = (name: string) =>
233 aiplatform
234 .getProjectsLocationsAgents({ name })
235 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
236
237const listAgents = (project: string, region: string) => {
238 const collect = (parent: string) =>
239 aiplatform.listProjectsLocationsAgents
240 .pages({ parent, pageSize: 100 })
241 .pipe(
242 Stream.flatMap((page) => Stream.fromIterable(page.agents ?? [])),
243 Stream.runCollect,
244 Effect.map((chunk) => Array.from(chunk)),
245 );
246 const fallback = Effect.forEach(listLocations(region), (location) =>
247 collect(`projects/${project}/locations/${location}`),
248 ).pipe(Effect.map((pages) => pages.flat()));
249 // Vertex AI has no `locations/-` wildcard; scan known locations.
250 return fallback.pipe(Effect.catchTag("NotFound", () => Effect.succeed([])));
251};
252
253const isOwnedAgent = (
254 agent: aiplatform.GoogleCloudAiplatformV1Agent,
255 id: string,
256) =>
257 Effect.gen(function* () {
258 if (yield* hasAlchemyLabels(id, tagRecord(agent.metadata))) return true;
259 const { labels } = parseOwnership(agent.description);
260 return yield* hasAlchemyLabels(id, labels);
261 });
262
263const findOwned = (
264 id: string,
265 project: string,
266 region: string,
267 hinted?: string,
268) =>
269 Effect.gen(function* () {
270 if (hinted !== undefined && hinted.length > 0) {
271 const existing = yield* getByName(hinted);
272 if (existing !== undefined) return existing;
273 }
274 const agents = yield* listAgents(project, region);
275 for (const agent of agents) {
276 if (yield* isOwnedAgent(agent, id)) return agent;
277 }
278 return undefined as aiplatform.GoogleCloudAiplatformV1Agent | undefined;
279 });
280
281const waitUntilExists = (name: string) =>
282 getByName(name).pipe(
283 Effect.flatMap((agent) =>
284 agent
285 ? Effect.succeed(agent)
286 : Effect.fail(new AgentNotResolved({ name })),
287 ),
288 Effect.retry({
289 while: (error) => error._tag === "GCP.AIPlatform.AgentNotResolved",
290 times: 8,
291 schedule: Schedule.spaced("1 second"),
292 }),
293 );
294
295const waitUntilGone = (name: string) =>
296 getByName(name).pipe(
297 Effect.flatMap((agent) =>
298 agent === undefined
299 ? Effect.void
300 : Effect.fail(new AgentStillExists({ name })),
301 ),
302 Effect.retry({
303 while: (error) => error._tag === "GCP.AIPlatform.AgentStillExists",
304 times: 10,
305 schedule: Schedule.spaced("2 seconds"),
306 }),
307 );
308
309export const AgentProvider = () =>
310 Provider.succeed(Agent, {
311 stables: ["name", "agentId", "project", "location", "created"],
312
313 diff: Effect.fn(function* ({ news, olds, output }) {
314 const env = yield* GcpEnvironment.current;
315 if (!isResolved(news)) return undefined;
316 const previousId = olds?.agentId ?? output?.agentId;
317 const nextId = news.agentId ?? previousId;
318 const previousLocation = normalizeLocation(
319 olds?.location ?? output?.location,
320 env.region,
321 );
322 const nextLocation = normalizeLocation(
323 news.location ?? olds?.location ?? output?.location,
324 env.region,
325 );
326 const previousBase =
327 olds?.baseAgent ?? output?.baseAgent ?? DEFAULT_BASE_AGENT;
328 const nextBase = news.baseAgent ?? previousBase;
329 const replace =
330 (previousId !== undefined &&
331 nextId !== undefined &&
332 rfc1035(nextId) !== previousId) ||
333 previousLocation !== nextLocation ||
334 previousBase !== nextBase;
335 if (!replace) return undefined;
336 return {
337 action: "replace" as const,
338 deleteFirst:
339 previousLocation === nextLocation &&
340 previousId !== undefined &&
341 rfc1035(nextId ?? previousId) === previousId,
342 };
343 }),
344
345 read: Effect.fn(function* ({ id, olds, output }) {
346 const env = yield* GcpEnvironment.current;
347 const agentId = yield* toId(id, olds?.agentId, output?.agentId);
348 const location = normalizeLocation(
349 olds?.location ?? output?.location,
350 env.region,
351 );
352 const name = output?.name ?? resourceName(env.project, location, agentId);
353 const existing = yield* findOwned(id, env.project, env.region, name);
354 if (existing === undefined) return undefined;
355 const attrs = toAttrs(existing, env.project);
356 return (yield* isOwnedAgent(existing, id)) ? attrs : Unowned(attrs);
357 }),
358
359 list: () =>
360 Effect.gen(function* () {
361 const env = yield* GcpEnvironment.current;
362 const agents = yield* listAgents(env.project, env.region);
363 return agents
364 .filter(
365 (agent) =>
366 hasAlchemyLabelKeys(agent.metadata) ||
367 hasOwnershipMarker(agent.description),
368 )
369 .map((agent) => toAttrs(agent, env.project));
370 }),
371
372 reconcile: Effect.fn(function* ({ id, news, output }) {
373 const env = yield* GcpEnvironment.current;
374 const location = normalizeLocation(
375 news.location ?? output?.location,
376 env.region,
377 );
378 const agentId = yield* toId(id, news.agentId, output?.agentId);
379 const name = resourceName(env.project, location, agentId);
380 const internal = yield* createInternalLabels(id);
381 const desiredMetadata = {
382 ...toLabels(news.metadata),
383 ...internal,
384 };
385 const desiredDescription = encodeOwnership(internal, news.description);
386 const baseAgent = news.baseAgent ?? DEFAULT_BASE_AGENT;
387 const tools = toolsOf(news.tools);
388
389 let current = yield* findOwned(
390 id,
391 env.project,
392 env.region,
393 output?.name ?? name,
394 );
395
396 if (current === undefined) {
397 const created = yield* aiplatform
398 .createProjectsLocationsAgents({
399 parent: parentOf(env.project, location),
400 body: {
401 id: agentId,
402 base_agent: baseAgent,
403 description: desiredDescription,
404 system_instruction: news.systemInstruction,
405 tools,
406 metadata: desiredMetadata,
407 base_environment: news.baseEnvironment,
408 },
409 })
410 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
411 if (created !== undefined) {
412 const done = yield* waitForOperation(created);
413 const createdName = resourceNameFromOperation(done) ?? name;
414 current = yield* waitUntilExists(createdName);
415 }
416 if (current === undefined) {
417 current = yield* findOwned(id, env.project, env.region, name);
418 }
419 }
420
421 if (current === undefined) {
422 return yield* new AgentNotResolved({ name });
423 }
424
425 const currentName = current.name ?? name;
426 const observedMetadata = tagRecord(current.metadata);
427 const { upsert, removed } = diffLabels(observedMetadata, desiredMetadata);
428 const metadataChanged = upsert.length > 0 || removed.length > 0;
429 const descriptionChanged =
430 (current.description ?? "") !== desiredDescription;
431 const instructionChanged =
432 (current.system_instruction ?? "") !== (news.systemInstruction ?? "");
433 const toolsChanged =
434 JSON.stringify(toolsOf(current.tools)) !== JSON.stringify(tools);
435
436 if (
437 metadataChanged ||
438 descriptionChanged ||
439 instructionChanged ||
440 toolsChanged
441 ) {
442 current = yield* aiplatform.patchProjectsLocationsAgents({
443 name: currentName,
444 updateMask: [
445 metadataChanged ? "metadata" : undefined,
446 descriptionChanged ? "description" : undefined,
447 instructionChanged ? "system_instruction" : undefined,
448 toolsChanged ? "tools" : undefined,
449 ]
450 .filter((field): field is string => field !== undefined)
451 .join(","),
452 body: {
453 name: currentName,
454 metadata: desiredMetadata,
455 description: desiredDescription,
456 system_instruction: news.systemInstruction,
457 tools,
458 },
459 });
460 }
461
462 return toAttrs(current, env.project);
463 }),
464
465 delete: Effect.fn(function* ({ output }) {
466 const existing = yield* getByName(output.name);
467 if (existing === undefined) return;
468 const operation = yield* aiplatform
469 .deleteProjectsLocationsAgents({ name: output.name })
470 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
471 if (operation !== undefined) {
472 yield* waitForOperation(operation, { notFoundOk: true });
473 }
474 yield* waitUntilGone(output.name);
475 }),
476 });