1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
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 type { Providers } from "../Providers.ts";
11import {
12 collectionParent,
13 DEFAULT_LOCATION,
14 encodeOwnershipLine,
15 expandAgent,
16 fingerprint,
17 hasOwnershipMarker,
18 internalLabels,
19 lastSegment,
20 locationOf,
21 namedAgents,
22 normalizeLocation,
23 ownedByAlchemy,
24 parentOf,
25 parseOwnership,
26 projectOf,
27 sameText,
28 toResourceId,
29 updateMaskOf,
30 retryQuota,
31} from "./internal.ts";
32
33export type GeneratorModelParameter = {
34 /** Sampling temperature. */
35 temperature?: number;
36 /** Maximum decode steps. */
37 maxDecodeSteps?: number;
38 /** Top-K sampling. */
39 topK?: number;
40 /** Top-P (nucleus) sampling. */
41 topP?: number;
42};
43
44export type GeneratorPlaceholder = {
45 /** Placeholder id referenced from the prompt. */
46 id?: string;
47 /** Human-readable placeholder name. */
48 name?: string;
49};
50
51export type GeneratorLlmModelSettings = {
52 /** LLM model id. */
53 model?: string;
54 /** Model prompt text. */
55 promptText?: string;
56};
57
58export type AgentsGeneratorProps = {
59 /**
60 * Parent agent resource name
61 * `projects/{project}/locations/{location}/agents/{agent}`.
62 * Immutable — changing it replaces the generator.
63 */
64 agent: string;
65 /**
66 * Generator id (the `{generator}` segment). Server-assigned when
67 * omitted. Immutable — changing it replaces the generator.
68 */
69 generatorId?: string;
70 /**
71 * Location used when `agent` is an id rather than a resource name.
72 * Immutable — changing it replaces the generator.
73 * @default "global"
74 */
75 location?: string;
76 /**
77 * Human-readable name. Generators have no labels field, so Alchemy
78 * stamps ownership into this field and strips it from attributes.
79 */
80 displayName?: string;
81 /**
82 * Prompt phrase the generator fills in.
83 * @default "Say hello."
84 */
85 promptText?: string;
86 /** Language code for the prompt (`en`, `en-US`, …). */
87 languageCode?: string;
88 /** Decoding parameters. */
89 modelParameter?: GeneratorModelParameter;
90 /** Placeholders referenced from `promptText`. */
91 placeholders?: GeneratorPlaceholder[];
92 /** LLM model settings. */
93 llmModelSettings?: GeneratorLlmModelSettings;
94};
95
96export type AgentsGenerator = Resource<
97 "GCP.Dialogflow.AgentsGenerator",
98 AgentsGeneratorProps,
99 {
100 /** Full resource name `.../agents/{agent}/generators/{generator}`. */
101 name: string;
102 /** Generator id (last path segment). */
103 generatorId: string;
104 /** Parent agent resource name. */
105 agent: string;
106 /** Location id. */
107 location: string;
108 /** Project id. */
109 project: string;
110 /** User display name with the Alchemy ownership prefix stripped. */
111 displayName: string | undefined;
112 /** Prompt text. */
113 promptText: string | undefined;
114 /** Decoding parameters. */
115 modelParameter: GeneratorModelParameter | undefined;
116 /** Placeholders. */
117 placeholders: GeneratorPlaceholder[] | undefined;
118 /** LLM model settings. */
119 llmModelSettings: GeneratorLlmModelSettings | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A Dialogflow CX generator that produces LLM-backed response text.
127 *
128 * Generators have no labels field — Alchemy stamps ownership into
129 * `displayName` so `list` / nuke can find them. Parent agent and id are
130 * immutable. Display name, prompt, model parameters, placeholders, and
131 * LLM settings update in place.
132 *
133 * ### Creating a Generator
134 * **Example:** Prompt generator
135 * ```typescript
136 * const generator = yield* GCP.Dialogflow.AgentsGenerator("Greeting", {
137 * agent: agentName,
138 * displayName: "greeting",
139 * promptText: "Greet the user by name.",
140 * });
141 * ```
142 *
143 * ### Updating a Generator
144 * Change props on the same logical id; the engine keeps the physical id.
145 *
146 * **Example:** Change the prompt
147 * ```typescript
148 * const generator = yield* GCP.Dialogflow.AgentsGenerator("Greeting", {
149 * agent: agentName,
150 * displayName: "greeting",
151 * promptText: "Greet the user warmly.",
152 * });
153 * ```
154 *
155 * @resource
156 * @category Dialogflow
157 */
158export const AgentsGenerator = Resource<AgentsGenerator>(
159 "GCP.Dialogflow.AgentsGenerator",
160);
161
162export class AgentsGeneratorNotResolved extends Data.TaggedError(
163 "GCP.Dialogflow.AgentsGeneratorNotResolved",
164)<{
165 name: string;
166}> {}
167
168const DEFAULT_PROMPT = "Say hello.";
169
170const resourceName = (agent: string, generatorId: string) =>
171 `${agent}/generators/${generatorId}`;
172
173const modelParameterOf = (
174 parameter:
175 | dialogflow.GoogleCloudDialogflowCxV3GeneratorModelParameter
176 | undefined,
177): GeneratorModelParameter | undefined => {
178 if (parameter === undefined) return undefined;
179 return {
180 temperature: parameter.temperature,
181 maxDecodeSteps: parameter.maxDecodeSteps,
182 topK: parameter.topK,
183 topP: parameter.topP,
184 };
185};
186
187const placeholdersOf = (
188 placeholders:
189 | dialogflow.GoogleCloudDialogflowCxV3GeneratorPlaceholderList
190 | undefined,
191): GeneratorPlaceholder[] | undefined => {
192 if (placeholders === undefined) return undefined;
193 return placeholders.map((placeholder) => ({
194 id: placeholder.id,
195 name: placeholder.name,
196 }));
197};
198
199const llmSettingsOf = (
200 settings: dialogflow.GoogleCloudDialogflowCxV3LlmModelSettings | undefined,
201): GeneratorLlmModelSettings | undefined => {
202 if (settings === undefined) return undefined;
203 return { model: settings.model, promptText: settings.promptText };
204};
205
206const toAttrs = (
207 generator: dialogflow.GoogleCloudDialogflowCxV3Generator,
208 project: string,
209 agentHint?: string,
210) => {
211 const name = generator.name ?? "";
212 const parsed = parseOwnership(generator.displayName);
213 return {
214 name,
215 generatorId: lastSegment(name),
216 agent: name.includes("/generators/")
217 ? collectionParent(name, "agents")
218 : (agentHint ?? parentOf(name)),
219 location: locationOf(name),
220 project: projectOf(name) || project,
221 displayName: parsed.text,
222 promptText: generator.promptText?.text,
223 modelParameter: modelParameterOf(generator.modelParameter),
224 placeholders: placeholdersOf(generator.placeholders),
225 llmModelSettings: llmSettingsOf(generator.llmModelSettings),
226 };
227};
228
229const getByName = (name: string) =>
230 name.length === 0
231 ? Effect.succeed(undefined)
232 : dialogflow
233 .getProjectsLocationsAgentsGenerators({ name })
234 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
235
236const listAt = (parent: string, project: string) =>
237 dialogflow.listProjectsLocationsAgentsGenerators
238 .pages({ parent, pageSize: 100 })
239 .pipe(
240 Stream.flatMap((page) => Stream.fromIterable(page.generators ?? [])),
241 Stream.filter((generator) => hasOwnershipMarker(generator.displayName)),
242 Stream.map((generator) => toAttrs(generator, project, parent)),
243 Stream.runCollect,
244 Effect.map((chunk) => Array.from(chunk)),
245 Effect.catchTag("NotFound", () => Effect.succeed([])),
246 );
247
248const findByDisplayName = (parent: string, displayName: string) =>
249 dialogflow.listProjectsLocationsAgentsGenerators
250 .pages({ parent, pageSize: 100 })
251 .pipe(
252 Stream.flatMap((page) => Stream.fromIterable(page.generators ?? [])),
253 Stream.filter((generator) => generator.displayName === displayName),
254 Stream.runHead,
255 Effect.map((option) =>
256 option._tag === "Some" ? option.value : undefined,
257 ),
258 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
259 );
260
261export const AgentsGeneratorProvider = () =>
262 Provider.succeed(AgentsGenerator, {
263 stables: ["name", "generatorId", "agent", "location", "project"],
264
265 diff: Effect.fn(function* ({ news, olds, output }) {
266 if (!isResolved(news)) return undefined;
267 const previousAgent = olds?.agent ?? output?.agent;
268 if (previousAgent !== undefined && news.agent !== previousAgent) {
269 return { action: "replace" as const, deleteFirst: false };
270 }
271 const previousId = olds?.generatorId ?? output?.generatorId;
272 if (
273 previousId !== undefined &&
274 news.generatorId !== undefined &&
275 news.generatorId !== previousId
276 ) {
277 return { action: "replace" as const, deleteFirst: false };
278 }
279 const previousLocation = olds?.location ?? output?.location;
280 if (
281 previousLocation !== undefined &&
282 news.location !== undefined &&
283 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
284 ) {
285 return { action: "replace" as const, deleteFirst: false };
286 }
287 return undefined;
288 }),
289
290 read: Effect.fn(function* ({ id, olds, output }) {
291 const env = yield* GcpEnvironment.current;
292 const location = normalizeLocation(olds?.location ?? output?.location);
293 const agent = olds?.agent
294 ? expandAgent(olds.agent, env.project, location)
295 : output?.agent;
296 const generatorId = yield* toResourceId(
297 id,
298 olds?.generatorId,
299 output?.generatorId,
300 );
301 const name =
302 output?.name ??
303 (agent !== undefined ? resourceName(agent, generatorId) : "");
304 let existing = yield* getByName(name);
305 if (existing === undefined && agent !== undefined) {
306 const ownership = yield* internalLabels(id);
307 existing = yield* findByDisplayName(
308 agent,
309 encodeOwnershipLine(ownership, olds?.displayName),
310 );
311 }
312 if (existing === undefined) return undefined;
313 const attrs = toAttrs(existing, env.project, agent);
314 return (yield* ownedByAlchemy(id, existing.displayName))
315 ? attrs
316 : Unowned(attrs);
317 }),
318
319 list: () =>
320 Effect.gen(function* () {
321 const env = yield* GcpEnvironment.current;
322 const agents = yield* namedAgents(env.project);
323 const pages = yield* Effect.forEach(
324 agents,
325 (agent) => listAt(agent.name, env.project),
326 { concurrency: 4 },
327 );
328 return pages.flat();
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 ?? DEFAULT_LOCATION,
335 );
336 const agent = expandAgent(news.agent, env.project, location);
337 const generatorId = yield* toResourceId(
338 id,
339 news.generatorId,
340 output?.generatorId,
341 );
342 const name = output?.name ?? resourceName(agent, generatorId);
343 const ownership = yield* internalLabels(id);
344 const displayName = encodeOwnershipLine(ownership, news.displayName);
345 const promptText = news.promptText ?? DEFAULT_PROMPT;
346 const body: dialogflow.GoogleCloudDialogflowCxV3Generator = {
347 displayName,
348 promptText: { text: promptText },
349 modelParameter: news.modelParameter,
350 placeholders: news.placeholders,
351 llmModelSettings: news.llmModelSettings,
352 };
353
354 let current = yield* getByName(output?.name ?? name);
355 if (current === undefined) {
356 current = yield* findByDisplayName(agent, displayName);
357 }
358
359 if (current === undefined) {
360 const created = yield* dialogflow
361 .createProjectsLocationsAgentsGenerators({
362 parent: agent,
363 languageCode: news.languageCode,
364 body,
365 })
366 .pipe(
367 Effect.catchTag("Conflict", () =>
368 findByDisplayName(agent, displayName),
369 ),
370 );
371 current = created ?? undefined;
372 }
373
374 if (current === undefined) {
375 return yield* new AgentsGeneratorNotResolved({ name });
376 }
377
378 const currentName = current.name ?? name;
379 const displayChanged = (current.displayName ?? "") !== displayName;
380 const promptChanged = !sameText(current.promptText?.text, promptText);
381 const modelChanged =
382 fingerprint(modelParameterOf(current.modelParameter)) !==
383 fingerprint(news.modelParameter);
384 const placeholdersChanged =
385 fingerprint(placeholdersOf(current.placeholders)) !==
386 fingerprint(news.placeholders);
387 const llmChanged =
388 fingerprint(llmSettingsOf(current.llmModelSettings)) !==
389 fingerprint(news.llmModelSettings);
390
391 if (
392 displayChanged ||
393 promptChanged ||
394 modelChanged ||
395 placeholdersChanged ||
396 llmChanged
397 ) {
398 current = yield* dialogflow.patchProjectsLocationsAgentsGenerators({
399 name: currentName,
400 languageCode: news.languageCode,
401 updateMask: updateMaskOf(
402 displayChanged ? "display_name" : undefined,
403 promptChanged ? "prompt_text" : undefined,
404 modelChanged ? "model_parameter" : undefined,
405 placeholdersChanged ? "placeholders" : undefined,
406 llmChanged ? "llm_model_settings" : undefined,
407 ),
408 body: { ...body, name: currentName },
409 });
410 }
411
412 return toAttrs(current, env.project, agent);
413 }, retryQuota),
414
415 delete: Effect.fn(function* ({ output }) {
416 yield* dialogflow
417 .deleteProjectsLocationsAgentsGenerators({
418 name: output.name,
419 force: true,
420 })
421 .pipe(Effect.catchTag("NotFound", () => Effect.void));
422 }, retryQuota),
423 });