1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 expandApp,
13 forEachApp,
14 normalizeLocation,
15 parseOwnership,
16 parseResourceName,
17 replaceOnIdentity,
18 retryTransient,
19 sameJson,
20 sameText,
21 toPhysicalId,
22 updateMaskOf,
23 waitUntilGone,
24} from "./internal.ts";
25
26export type AppsAgentProps = {
27 /**
28 * Parent CES app. Full name
29 * `projects/{project}/locations/{location}/apps/{app}` or the app id
30 * (combined with `location`). Immutable — changing it replaces the
31 * agent.
32 */
33 app: string;
34 /**
35 * Region used when `app` is a bare id.
36 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
37 */
38 location?: string;
39 /**
40 * Agent id (the `{agent}` segment). If omitted, a unique name is
41 * generated. Immutable — changing it replaces the agent.
42 */
43 agentId?: string;
44 /**
45 * Human-readable name. Required by the API; Alchemy falls back to the
46 * generated agent id.
47 */
48 displayName?: string;
49 /**
50 * Human-readable description.
51 */
52 description?: string;
53 /**
54 * Instructions for the LLM.
55 */
56 instruction?: string;
57 /**
58 * LLM model settings for this agent.
59 */
60 modelSettings?: ces.ModelSettings;
61 /**
62 * Tool resource names available to the agent.
63 */
64 tools?: string[];
65 /**
66 * Toolset selections.
67 */
68 toolsets?: ces.AgentAgentToolsetList;
69 /**
70 * Child agent resource names.
71 */
72 childAgents?: string[];
73 /**
74 * Guardrail resource names.
75 */
76 guardrails?: string[];
77 /**
78 * Agent transfer rules.
79 */
80 transferRules?: ces.TransferRuleList;
81 /**
82 * When set, the agent delegates to a Dialogflow CX agent and other
83 * agent-level properties are ignored.
84 */
85 remoteDialogflowAgent?: ces.AgentRemoteDialogflowAgent;
86};
87
88export type AppsAgent = Resource<
89 "GCP.CES.AppsAgent",
90 AppsAgentProps,
91 {
92 /** Full resource name `.../apps/{app}/agents/{agent}`. */
93 name: string;
94 /** Agent id (last path segment). */
95 agentId: string;
96 /** Parent app resource name. */
97 app: string;
98 /** Location id. */
99 location: string;
100 /** Project id. */
101 project: string;
102 /** Display name. */
103 displayName: string | undefined;
104 /** User description with the Alchemy ownership prefix stripped. */
105 description: string | undefined;
106 /** LLM instructions. */
107 instruction: string | undefined;
108 /** Model settings. */
109 modelSettings: ces.ModelSettings | undefined;
110 /** Tool resource names. */
111 tools: string[] | undefined;
112 /** Toolset selections. */
113 toolsets: ces.AgentAgentToolsetList | undefined;
114 /** Child agent resource names. */
115 childAgents: string[] | undefined;
116 /** Guardrail resource names. */
117 guardrails: string[] | undefined;
118 /** Transfer rules. */
119 transferRules: ces.TransferRuleList | undefined;
120 /** Remote Dialogflow agent, if any. */
121 remoteDialogflowAgent: ces.AgentRemoteDialogflowAgent | undefined;
122 /** RFC3339 creation timestamp. */
123 createTime: string | undefined;
124 /** RFC3339 last-update timestamp. */
125 updateTime: string | undefined;
126 /** Server-assigned etag. */
127 etag: string | undefined;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A Customer Engagement Suite agent inside an app. Agents give the LLM
135 * instructions, tools, and transfer rules for a task.
136 *
137 * Agents have no labels field, and the description reaches the model, so
138 * Alchemy writes no ownership marker: a generated agent id identifies the
139 * agent, and `read` reports an explicit id it has no state for as
140 * unowned. Parent app, location, and agent id are immutable.
141 *
142 * ### Creating an Agent
143 * **Example:** Root LLM agent
144 * ```typescript
145 * const agent = yield* GCP.CES.AppsAgent("Greeter", {
146 * app: app.name,
147 * displayName: "greeter",
148 * instruction: "Greet the user and offer help.",
149 * });
150 * ```
151 *
152 * @resource
153 * @category CES
154 */
155export const AppsAgent = Resource<AppsAgent>("GCP.CES.AppsAgent");
156
157export class AppsAgentNotResolved extends Data.TaggedError(
158 "GCP.CES.AppsAgentNotResolved",
159)<{
160 name: string;
161}> {}
162
163const resourceName = (app: string, agentId: string) =>
164 `${app}/agents/${agentId}`;
165
166const toAttrs = (
167 agent: ces.Agent,
168 project: string,
169 region: string,
170 appHint?: string,
171) => {
172 const name = agent.name ?? "";
173 const parsed = parseResourceName(name, "agents", region);
174 return {
175 name,
176 agentId: parsed.id,
177 app: name.includes("/agents/") ? parsed.app : (appHint ?? parsed.parent),
178 location: parsed.location,
179 project: parsed.project || project,
180 displayName: agent.displayName,
181 description: parseOwnership(agent.description).text,
182 instruction: agent.instruction,
183 modelSettings: agent.modelSettings,
184 tools: agent.tools,
185 toolsets: agent.toolsets,
186 childAgents: agent.childAgents,
187 guardrails: agent.guardrails,
188 transferRules: agent.transferRules,
189 remoteDialogflowAgent: agent.remoteDialogflowAgent,
190 createTime: agent.createTime,
191 updateTime: agent.updateTime,
192 etag: agent.etag,
193 };
194};
195
196const getByName = (name: string) =>
197 name.length === 0
198 ? Effect.succeed(undefined)
199 : ces
200 .getProjectsLocationsAppsAgents({ name })
201 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
202
203const listAt = (parent: string, project: string, region: string) =>
204 collectPages(
205 ces.listProjectsLocationsAppsAgents.pages({ parent, pageSize: 100 }),
206 (page) => page.agents,
207 ).pipe(
208 Effect.map((agents) =>
209 agents.map((agent) => toAttrs(agent, project, region, parent)),
210 ),
211 );
212
213export const AppsAgentProvider = () =>
214 Provider.succeed(AppsAgent, {
215 stables: ["name", "agentId", "app", "location", "project", "createTime"],
216
217 diff: Effect.fn(function* ({ news, olds, output }) {
218 if (!isResolved(news)) return undefined;
219 const env = yield* GcpEnvironment.current;
220 const previousLocation = olds?.location ?? output?.location;
221 const nextLocation = normalizeLocation(news.location, env.region);
222 if (
223 previousLocation !== undefined &&
224 news.location !== undefined &&
225 normalizeLocation(previousLocation, env.region) !== nextLocation
226 ) {
227 return { action: "replace" as const, deleteFirst: false };
228 }
229 return replaceOnIdentity({
230 previousId: olds?.agentId ?? output?.agentId,
231 nextId: news.agentId,
232 previousParent: olds?.app ?? output?.app,
233 nextParent: news.app,
234 });
235 }),
236
237 read: Effect.fn(function* ({ id, olds, output }) {
238 const env = yield* GcpEnvironment.current;
239 const location = normalizeLocation(
240 olds?.location ?? output?.location,
241 env.region,
242 );
243 const app = olds?.app
244 ? expandApp(olds.app, env.project, location)
245 : output?.app;
246 const agentId = yield* toPhysicalId(id, olds?.agentId, output?.agentId);
247 const name =
248 output?.name ?? (app !== undefined ? resourceName(app, agentId) : "");
249 const existing = yield* getByName(name);
250 if (existing === undefined) return undefined;
251 const attrs = toAttrs(existing, env.project, env.region, app);
252 return output !== undefined || olds?.agentId === undefined
253 ? attrs
254 : Unowned(attrs);
255 }),
256
257 list: () =>
258 Effect.gen(function* () {
259 const env = yield* GcpEnvironment.current;
260 return yield* forEachApp(
261 env.project,
262 (parent) => listAt(parent, env.project, env.region),
263 env.region,
264 );
265 }),
266
267 reconcile: Effect.fn(function* ({ id, news, output }) {
268 const env = yield* GcpEnvironment.current;
269 const location = normalizeLocation(
270 news.location ?? output?.location,
271 env.region,
272 );
273 const app = expandApp(news.app, env.project, location);
274 const agentId = yield* toPhysicalId(id, news.agentId, output?.agentId);
275 const name = output?.name ?? resourceName(app, agentId);
276 const description = news.description;
277 const displayName = news.displayName ?? agentId;
278 const llmAgent =
279 news.remoteDialogflowAgent === undefined ? {} : undefined;
280
281 let current = yield* getByName(name);
282
283 if (current === undefined) {
284 const created = yield* retryTransient(
285 ces.createProjectsLocationsAppsAgents({
286 parent: app,
287 agentId,
288 body: {
289 displayName,
290 description,
291 instruction: news.instruction,
292 modelSettings: news.modelSettings,
293 tools: news.tools,
294 toolsets: news.toolsets,
295 childAgents: news.childAgents,
296 guardrails: news.guardrails,
297 transferRules: news.transferRules,
298 remoteDialogflowAgent: news.remoteDialogflowAgent,
299 llmAgent,
300 },
301 }),
302 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
303 current = created ?? undefined;
304 }
305
306 if (current === undefined) {
307 return yield* new AppsAgentNotResolved({ name });
308 }
309
310 const currentName = current.name ?? name;
311 const displayChanged = !sameText(current.displayName, displayName);
312 const descriptionChanged = !sameText(current.description, description);
313 const instructionChanged = !sameText(
314 current.instruction,
315 news.instruction,
316 );
317 const modelChanged = !sameJson(current.modelSettings, news.modelSettings);
318 const toolsChanged = !sameJson(current.tools, news.tools);
319 const toolsetsChanged = !sameJson(current.toolsets, news.toolsets);
320 const childrenChanged = !sameJson(current.childAgents, news.childAgents);
321 const guardrailsChanged = !sameJson(current.guardrails, news.guardrails);
322 const rulesChanged = !sameJson(current.transferRules, news.transferRules);
323 const remoteChanged = !sameJson(
324 current.remoteDialogflowAgent,
325 news.remoteDialogflowAgent,
326 );
327
328 if (
329 displayChanged ||
330 descriptionChanged ||
331 instructionChanged ||
332 modelChanged ||
333 toolsChanged ||
334 toolsetsChanged ||
335 childrenChanged ||
336 guardrailsChanged ||
337 rulesChanged ||
338 remoteChanged
339 ) {
340 current = yield* retryTransient(
341 ces.patchProjectsLocationsAppsAgents({
342 name: currentName,
343 updateMask: updateMaskOf(
344 displayChanged ? "display_name" : undefined,
345 descriptionChanged ? "description" : undefined,
346 instructionChanged ? "instruction" : undefined,
347 modelChanged ? "model_settings" : undefined,
348 toolsChanged ? "tools" : undefined,
349 toolsetsChanged ? "toolsets" : undefined,
350 childrenChanged ? "child_agents" : undefined,
351 guardrailsChanged ? "guardrails" : undefined,
352 rulesChanged ? "transfer_rules" : undefined,
353 remoteChanged ? "remote_dialogflow_agent" : undefined,
354 ),
355 body: {
356 displayName,
357 description,
358 instruction: news.instruction,
359 modelSettings: news.modelSettings,
360 tools: news.tools,
361 toolsets: news.toolsets,
362 childAgents: news.childAgents,
363 guardrails: news.guardrails,
364 transferRules: news.transferRules,
365 remoteDialogflowAgent: news.remoteDialogflowAgent,
366 llmAgent,
367 },
368 }),
369 );
370 }
371
372 return toAttrs(current, env.project, env.region, app);
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 if (!output.name) return;
377 yield* retryTransient(
378 ces.deleteProjectsLocationsAppsAgents({
379 name: output.name,
380 force: true,
381 }),
382 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
383 yield* waitUntilGone(getByName(output.name));
384 }),
385 });